diff --git includes/LICENSE.txt includes/LICENSE.txt
new file mode 100644
index 0000000..10a0692
--- /dev/null
+++ includes/LICENSE.txt
@@ -0,0 +1,276 @@
+GNU GENERAL PUBLIC LICENSE
+
+              Version 2, June 1991
+
+Copyright (C) 1989, 1991 Free Software Foundation, Inc.  
+51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA
+
+Everyone is permitted to copy and distribute verbatim copies
+of this license document, but changing it is not allowed.
+
+                  Preamble
+
+The licenses for most software are designed to take away your freedom to
+share and change it. By contrast, the GNU General Public License is
+intended to guarantee your freedom to share and change free software--to
+make sure the software is free for all its users. This General Public License
+applies to most of the Free Software Foundation's software and to any other
+program whose authors commit to using it. (Some other Free Software
+Foundation software is covered by the GNU Library General Public License
+instead.) You can apply it to your programs, too.
+
+When we speak of free software, we are referring to freedom, not price. Our
+General Public Licenses are designed to make sure that you have the
+freedom to distribute copies of free software (and charge for this service if
+you wish), that you receive source code or can get it if you want it, that you
+can change the software or use pieces of it in new free programs; and that
+you know you can do these things.
+
+To protect your rights, we need to make restrictions that forbid anyone to
+deny you these rights or to ask you to surrender the rights. These restrictions
+translate to certain responsibilities for you if you distribute copies of the
+software, or if you modify it.
+
+For example, if you distribute copies of such a program, whether gratis or for
+a fee, you must give the recipients all the rights that you have. You must make
+sure that they, too, receive or can get the source code. And you must show
+them these terms so they know their rights.
+
+We protect your rights with two steps: (1) copyright the software, and (2)
+offer you this license which gives you legal permission to copy, distribute
+and/or modify the software.
+
+Also, for each author's protection and ours, we want to make certain that
+everyone understands that there is no warranty for this free software. If the
+software is modified by someone else and passed on, we want its recipients
+to know that what they have is not the original, so that any problems
+introduced by others will not reflect on the original authors' reputations.
+
+Finally, any free program is threatened constantly by software patents. We
+wish to avoid the danger that redistributors of a free program will individually
+obtain patent licenses, in effect making the program proprietary. To prevent
+this, we have made it clear that any patent must be licensed for everyone's
+free use or not licensed at all.
+
+The precise terms and conditions for copying, distribution and modification
+follow.
+
+           GNU GENERAL PUBLIC LICENSE
+ TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND
+               MODIFICATION
+
+0. This License applies to any program or other work which contains a notice
+placed by the copyright holder saying it may be distributed under the terms
+of this General Public License. The "Program", below, refers to any such
+program or work, and a "work based on the Program" means either the
+Program or any derivative work under copyright law: that is to say, a work
+containing the Program or a portion of it, either verbatim or with
+modifications and/or translated into another language. (Hereinafter, translation
+is included without limitation in the term "modification".) Each licensee is
+addressed as "you".
+
+Activities other than copying, distribution and modification are not covered
+by this License; they are outside its scope. The act of running the Program is
+not restricted, and the output from the Program is covered only if its contents
+constitute a work based on the Program (independent of having been made
+by running the Program). Whether that is true depends on what the Program
+does.
+
+1. You may copy and distribute verbatim copies of the Program's source
+code as you receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice and
+disclaimer of warranty; keep intact all the notices that refer to this License
+and to the absence of any warranty; and give any other recipients of the
+Program a copy of this License along with the Program.
+
+You may charge a fee for the physical act of transferring a copy, and you
+may at your option offer warranty protection in exchange for a fee.
+
+2. You may modify your copy or copies of the Program or any portion of it,
+thus forming a work based on the Program, and copy and distribute such
+modifications or work under the terms of Section 1 above, provided that you
+also meet all of these conditions:
+
+a) You must cause the modified files to carry prominent notices stating that
+you changed the files and the date of any change.
+
+b) You must cause any work that you distribute or publish, that in whole or in
+part contains or is derived from the Program or any part thereof, to be
+licensed as a whole at no charge to all third parties under the terms of this
+License.
+
+c) If the modified program normally reads commands interactively when run,
+you must cause it, when started running for such interactive use in the most
+ordinary way, to print or display an announcement including an appropriate
+copyright notice and a notice that there is no warranty (or else, saying that
+you provide a warranty) and that users may redistribute the program under
+these conditions, and telling the user how to view a copy of this License.
+(Exception: if the Program itself is interactive but does not normally print such
+an announcement, your work based on the Program is not required to print
+an announcement.)
+
+These requirements apply to the modified work as a whole. If identifiable
+sections of that work are not derived from the Program, and can be
+reasonably considered independent and separate works in themselves, then
+this License, and its terms, do not apply to those sections when you distribute
+them as separate works. But when you distribute the same sections as part
+of a whole which is a work based on the Program, the distribution of the
+whole must be on the terms of this License, whose permissions for other
+licensees extend to the entire whole, and thus to each and every part
+regardless of who wrote it.
+
+Thus, it is not the intent of this section to claim rights or contest your rights to
+work written entirely by you; rather, the intent is to exercise the right to
+control the distribution of derivative or collective works based on the
+Program.
+
+In addition, mere aggregation of another work not based on the Program
+with the Program (or with a work based on the Program) on a volume of a
+storage or distribution medium does not bring the other work under the scope
+of this License.
+
+3. You may copy and distribute the Program (or a work based on it, under
+Section 2) in object code or executable form under the terms of Sections 1
+and 2 above provided that you also do one of the following:
+
+a) Accompany it with the complete corresponding machine-readable source
+code, which must be distributed under the terms of Sections 1 and 2 above
+on a medium customarily used for software interchange; or,
+
+b) Accompany it with a written offer, valid for at least three years, to give
+any third party, for a charge no more than your cost of physically performing
+source distribution, a complete machine-readable copy of the corresponding
+source code, to be distributed under the terms of Sections 1 and 2 above on
+a medium customarily used for software interchange; or,
+
+c) Accompany it with the information you received as to the offer to distribute
+corresponding source code. (This alternative is allowed only for
+noncommercial distribution and only if you received the program in object
+code or executable form with such an offer, in accord with Subsection b
+above.)
+
+The source code for a work means the preferred form of the work for
+making modifications to it. For an executable work, complete source code
+means all the source code for all modules it contains, plus any associated
+interface definition files, plus the scripts used to control compilation and
+installation of the executable. However, as a special exception, the source
+code distributed need not include anything that is normally distributed (in
+either source or binary form) with the major components (compiler, kernel,
+and so on) of the operating system on which the executable runs, unless that
+component itself accompanies the executable.
+
+If distribution of executable or object code is made by offering access to
+copy from a designated place, then offering equivalent access to copy the
+source code from the same place counts as distribution of the source code,
+even though third parties are not compelled to copy the source along with the
+object code.
+
+4. You may not copy, modify, sublicense, or distribute the Program except as
+expressly provided under this License. Any attempt otherwise to copy,
+modify, sublicense or distribute the Program is void, and will automatically
+terminate your rights under this License. However, parties who have received
+copies, or rights, from you under this License will not have their licenses
+terminated so long as such parties remain in full compliance.
+
+5. You are not required to accept this License, since you have not signed it.
+However, nothing else grants you permission to modify or distribute the
+Program or its derivative works. These actions are prohibited by law if you
+do not accept this License. Therefore, by modifying or distributing the
+Program (or any work based on the Program), you indicate your acceptance
+of this License to do so, and all its terms and conditions for copying,
+distributing or modifying the Program or works based on it.
+
+6. Each time you redistribute the Program (or any work based on the
+Program), the recipient automatically receives a license from the original
+licensor to copy, distribute or modify the Program subject to these terms and
+conditions. You may not impose any further restrictions on the recipients'
+exercise of the rights granted herein. You are not responsible for enforcing
+compliance by third parties to this License.
+
+7. If, as a consequence of a court judgment or allegation of patent
+infringement or for any other reason (not limited to patent issues), conditions
+are imposed on you (whether by court order, agreement or otherwise) that
+contradict the conditions of this License, they do not excuse you from the
+conditions of this License. If you cannot distribute so as to satisfy
+simultaneously your obligations under this License and any other pertinent
+obligations, then as a consequence you may not distribute the Program at all.
+For example, if a patent license would not permit royalty-free redistribution
+of the Program by all those who receive copies directly or indirectly through
+you, then the only way you could satisfy both it and this License would be to
+refrain entirely from distribution of the Program.
+
+If any portion of this section is held invalid or unenforceable under any
+particular circumstance, the balance of the section is intended to apply and
+the section as a whole is intended to apply in other circumstances.
+
+It is not the purpose of this section to induce you to infringe any patents or
+other property right claims or to contest validity of any such claims; this
+section has the sole purpose of protecting the integrity of the free software
+distribution system, which is implemented by public license practices. Many
+people have made generous contributions to the wide range of software
+distributed through that system in reliance on consistent application of that
+system; it is up to the author/donor to decide if he or she is willing to
+distribute software through any other system and a licensee cannot impose
+that choice.
+
+This section is intended to make thoroughly clear what is believed to be a
+consequence of the rest of this License.
+
+8. If the distribution and/or use of the Program is restricted in certain
+countries either by patents or by copyrighted interfaces, the original copyright
+holder who places the Program under this License may add an explicit
+geographical distribution limitation excluding those countries, so that
+distribution is permitted only in or among countries not thus excluded. In such
+case, this License incorporates the limitation as if written in the body of this
+License.
+
+9. The Free Software Foundation may publish revised and/or new versions
+of the General Public License from time to time. Such new versions will be
+similar in spirit to the present version, but may differ in detail to address new
+problems or concerns.
+
+Each version is given a distinguishing version number. If the Program specifies
+a version number of this License which applies to it and "any later version",
+you have the option of following the terms and conditions either of that
+version or of any later version published by the Free Software Foundation. If
+the Program does not specify a version number of this License, you may
+choose any version ever published by the Free Software Foundation.
+
+10. If you wish to incorporate parts of the Program into other free programs
+whose distribution conditions are different, write to the author to ask for
+permission. For software which is copyrighted by the Free Software
+Foundation, write to the Free Software Foundation; we sometimes make
+exceptions for this. Our decision will be guided by the two goals of
+preserving the free status of all derivatives of our free software and of
+promoting the sharing and reuse of software generally.
+
+               NO WARRANTY
+
+11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE,
+THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT
+PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE
+STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR
+OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT
+WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED,
+INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
+OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND
+PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
+PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL
+NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR
+AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR
+ANY OTHER PARTY WHO MAY MODIFY AND/OR
+REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE
+LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL,
+SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES
+ARISING OUT OF THE USE OR INABILITY TO USE THE
+PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA
+OR DATA BEING RENDERED INACCURATE OR LOSSES
+SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE
+PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN
+IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF
+THE POSSIBILITY OF SUCH DAMAGES.
+
+          END OF TERMS AND CONDITIONS
diff --git includes/README.txt includes/README.txt
new file mode 100644
index 0000000..d8837ae
--- /dev/null
+++ includes/README.txt
@@ -0,0 +1,24 @@
+
+-- SUMMARY --
+
+A generic Mollom client PHP class.
+
+To submit bug reports and feature suggestions, or to track changes:
+  http://drupal.org/project/issues/mollom
+
+
+-- REQUIREMENTS --
+
+* PHP 5
+
+
+-- USAGE --
+
+* @todo
+
+
+-- LICENSE --
+
+The Mollom class is released under the GNU General Public License, Version 2.
+See LICENSE.txt.
+
diff --git includes/mollom.class.inc includes/mollom.class.inc
new file mode 100644
index 0000000..d190672
--- /dev/null
+++ includes/mollom.class.inc
@@ -0,0 +1,1283 @@
+<?php
+
+/**
+ * @file
+ * Mollom client class.
+ *
+ * @license http://www.gnu.org/licenses/old-licenses/gpl-2.0.html GNU GPL v2
+ *
+ * @todo Refine exceptions by necessity of having to react differently.
+ *   - All 5xx HTTP codes, MollomRedirectException, and MollomResponseException
+ *     should make the client try the next server.
+ *   - Only AUTH_ERROR and 404 are not recoverable.
+ * @todo Doing a "verifyKey" with an empty server list leads to two/duplicate
+ *   and unnecessary GET site/$id requests, one for the server list, subsequent
+ *   ones for whatever/verifyKey. Some resources can be cached?
+ */
+
+/**
+ * A catchable Mollom exception.
+ *
+ * The Mollom class internally uses exceptions to handle client-side server
+ * balancing and eventually HTTP request errors within the Mollom::request()
+ * method. All exceptions thrown in the Mollom class and derived classes should
+ * be instances of the MollomException class if they pertain to errors that can
+ * be catched/handled within the class. Other errors should not use the
+ * MollomException class and handled differently.
+ *
+ * No MollomException is supposed to pile up as a user-facing fatal error. All
+ * functions that invoke Mollom::request() have to catch Mollom exceptions.
+ *
+ * @see Mollom::query()
+ * @see Mollom::request()
+ *
+ * @param $message
+ *   The Exception message to throw.
+ * @param $code
+ *   The Exception code.
+ * @param $previous
+ *   (optional) The previous Exception, if any.
+ * @param $instance
+ *   The Mollom class instance the Exception is thrown in.
+ * @param $arguments
+ *   (optional) A associative array containing information about a performed
+ *   HTTP request that failed:
+ *   - request: (string) The HTTP method and URI of the performed request; e.g.,
+ *     "GET http://server.mollom.com/v1/foo/bar". In case of GET requests, do
+ *     not add query parameters to the URI; pass them in 'data' instead.
+ *   - data: (array) An associative array containing HTTP GET/POST/PUT request
+ *     query parameters that were sent to the server.
+ *   - response: (mixed) The server response, either as string, or the already
+ *     parsed response; i.e., an array.
+ */
+class MollomException extends Exception {
+  /**
+   * @var Mollom
+   */
+  protected $mollom;
+
+  /**
+   * The severity of this exception.
+   *
+   * @var string
+   */
+  protected $severity = 'debug';
+
+  /**
+   * Overrides Exception::__construct().
+   */
+  function __construct($message = '', $code = 0, Exception $previous = NULL, Mollom $mollom, array $arguments = array()) {
+    // Fatal error on PHP <5.3 when passing more arguments to Exception.
+    if (version_compare(phpversion(), '5.3') >= 0) {
+      parent::__construct($message, $code, $previous);
+    }
+    else {
+      parent::__construct($message, $code);
+    }
+
+    // Set the error code on the Mollom class.
+    $mollom->lastResponseCode = $code;
+
+    // Log the exception.
+    $message = array(
+      'type' => $this->severity,
+      'message' => 'Error @code: %message',
+      'arguments' => array(
+        '@code' => $code,
+        '%message' => $message,
+      ),
+    );
+    // Add HTTP request information, if available.
+    if (!empty($arguments)) {
+      $message += array(
+        'request' => $arguments['request'],
+        'data' => $arguments['data'],
+        'response' => $arguments['response'],
+      );
+    }
+    $mollom->log[] = $message;
+  }
+}
+
+/**
+ * Mollom network error exception.
+ *
+ * Thrown in case a HTTP request results in code <= 0, denoting a low-level
+ * communication error.
+ */
+class MollomNetworkException extends MollomException {
+  protected $severity = 'error';
+}
+
+/**
+ * Mollom authentication error exception.
+ *
+ * Thrown in case API keys or other authentication parameters are invalid.
+ */
+class MollomAuthenticationException extends MollomException {
+  protected $severity = 'error';
+}
+
+/**
+ * Mollom server refresh exception.
+ *
+ * Thrown when a Mollom server asks the client to update the server list.
+ */
+class MollomRefreshException extends MollomException {
+  protected $severity = 'debug';
+}
+
+/**
+ * Mollom server redirect exception.
+ *
+ * Thrown when a Mollom server asks the client to use next server in the server
+ * list.
+ */
+class MollomRedirectException extends MollomException {
+  protected $severity = 'debug';
+}
+
+/**
+ * Mollom server response exception.
+ *
+ * Thrown when a request to a Mollom server succeeds, but the response does not
+ * contain an expected element; e.g., a backend configuration or execution
+ * error that possibly exists on one server only.
+ *
+ * @see Mollom::request()
+ */
+class MollomResponseException extends MollomException {
+  protected $severity = 'debug';
+}
+
+/**
+ * The base class for Mollom client implementations.
+ */
+abstract class Mollom {
+  /**
+   * The Mollom API version, used in HTTP requests.
+   */
+  const API_VERSION = 'v1';
+
+  /**
+   * Network communication failure code: No servers could be reached.
+   *
+   * @see MollomNetworkException
+   */
+  const NETWORK_ERROR = 900;
+
+  /**
+   * Server communication failure code: Unexpected server response.
+   *
+   * Using the 5xx HTTP status code range, but not re-using an existing HTTP
+   * code to prevent bogus bug reports. 511 is the closest comparable code
+   * 501 (Not Implemented) plus 10.
+   *
+   * @see MollomResponseException
+   */
+  const RESPONSE_ERROR = 511;
+
+  /**
+   * Server communication failure code: Authentication error.
+   *
+   * @see MollomAuthenticationException
+   */
+  const AUTH_ERROR = 1000;
+
+  /**
+   * Server communication failure code: Client is asked to update the server list.
+   *
+   * @see MollomRefreshException
+   */
+  const REFRESH_ERROR = 1100;
+
+  /**
+   * Server communication failure code: Client is asked to try next server.
+   *
+   * @see MollomRedirectException
+   */
+  const REDIRECT_ERROR = 1200;
+
+  /**
+   * The public Mollom API key to use for request authentication.
+   *
+   * @var string
+   */
+  public $publicKey = '';
+
+  /**
+   * The private Mollom API key to use for request authentication.
+   *
+   * @var string
+   */
+  public $privateKey = '';
+
+  /**
+   * The list of Mollom servers to communicate with, as returned by Mollom.
+   *
+   * @var array
+   *
+   * @see Mollom::getServers()
+   */
+  public $servers = array();
+
+  /**
+   * A hard-coded list of Mollom servers to fetch the server list from.
+   *
+   * @var array
+   */
+  public $serversInit = array('http://rest.mollom.com');
+
+  /**
+   * The status code of the last response or TRUE if it succeeded.
+   *
+   * @var int|bool|null
+   */
+  public $lastResponseCode = NULL;
+
+  /**
+   * A list of logged requests.
+   *
+   * @var array
+   */
+  public $log = array();
+
+  function __construct() {
+    $this->publicKey = $this->loadConfiguration('publicKey');
+    $this->privateKey = $this->loadConfiguration('privateKey');
+  }
+
+  /**
+   * Loads a configuration value from client-side storage.
+   *
+   * @param string $name
+   *   The configuration setting name to load, one of:
+   *   - publicKey: The public API key for Mollom authentication.
+   *   - privateKey: The private API key for Mollom authentication.
+   *   - servers: An indexed array of Mollom server addresses.
+   *
+   * @return mixed
+   *   The stored configuration value or NULL if there is none.
+   *
+   * @see Mollom::saveConfiguration()
+   * @see Mollom::deleteConfiguration()
+   */
+  abstract public function loadConfiguration($name);
+
+  /**
+   * Saves a configuration value to client-side storage.
+   *
+   * @param string $name
+   *   The configuration setting name to save.
+   * @param mixed $value
+   *   The value to save.
+   *
+   * @see Mollom::loadConfiguration()
+   * @see Mollom::deleteConfiguration()
+   */
+  abstract public function saveConfiguration($name, $value);
+
+  /**
+   * Deletes a configuration value from client-side storage.
+   *
+   * @param string $name
+   *   The configuration setting name to delete.
+   *
+   * @see Mollom::loadConfiguration()
+   * @see Mollom::saveConfiguration()
+   */
+  abstract public function deleteConfiguration($name);
+
+  /**
+   * Returns platform and version information about the Mollom client.
+   *
+   * Retrieves platform and Mollom client version information to send along to
+   * Mollom when verifying keys.
+   *
+   * This information is used to speed up support requests and technical
+   * inquiries. The data may also be aggregated to help the Mollom staff to make
+   * decisions on new features or the necessity of back-porting improved
+   * functionality to older versions.
+   *
+   * @return array
+   *   An associative array containing:
+   *   - platformName: The name of the platform/distribution; e.g., "Drupal".
+   *   - platformVersion: The version of platform/distribution; e.g., "7.0".
+   *   - clientName: The official Mollom client name; e.g., "Mollom".
+   *   - clientVersion: The version of the Mollom client; e.g., "7.x-1.0".
+   */
+  abstract public function getClientInformation();
+
+  /**
+   * Writes log messages to a permanent location/storage.
+   *
+   * Not abstract, since clients are not required to write log messages.
+   * However, all clients should permanently store the log messages, as it
+   * dramatically improves resolution of support requests filed by users.
+   * The log may be written and appended to a file (via file_put_contents()),
+   * syslog (on *nix-based systems), or a database.
+   *
+   * @see Mollom::log
+   */
+  public function writeLog() {
+    // After writing log messages, empty the log.
+    $this->purgeLog();
+  }
+
+  /**
+   * Purges captured log messages.
+   *
+   * @see Mollom::writeLog()
+   */
+  final public function purgeLog() {
+    $this->log = array();
+  }
+
+  /**
+   * Returns the current request time as UNIX timestamp.
+   *
+   * @return integer
+   */
+  public function getRequestTime() {
+    return $_SERVER['REQUEST_TIME'];
+  }
+
+  /**
+   * Generates authentication parameters for communication with Mollom servers.
+   *
+   * This function generates an array with all information required to
+   * authenticate against Mollom. To prevent forged requests where you are
+   * impersonated, each request is signed with a hash based on a private
+   * key and a timestamp.
+   *
+   * Both the client and the server share the secret key used to create
+   * the authentication hash. They both hash a timestamp with the secret
+   * key, and if the hashes match, the authenticity of the message is
+   * validated.
+   *
+   * To avoid someone intercepting a (hash, timestamp)-pair and using it
+   * to impersonate a client, Mollom rejects any request where the timestamp
+   * is more than 15 minutes off.
+   *
+   * Make sure your server's time is synchronized with the world clocks,
+   * and that you don't share your private key with anyone else.
+   *
+   * @return array
+   *   An associative array containing HMAC authentication request parameters
+   *   to be sent to Mollom.
+   *
+   * @throws MollomAuthenticationException
+   */
+  public function getAuthentication() {
+    if (empty($this->publicKey) || empty($this->privateKey)) {
+      throw new MollomAuthenticationException('Missing API keys.', self::AUTH_ERROR, NULL, $this);
+    }
+
+    // Generate a timestamp according to the dateTime format.
+    // @see http://www.w3.org/TR/xmlschema-2/#dateTime
+    $time = gmdate("Y-m-d\TH:i:s.\\0\\0\\0O", $this->getRequestTime());
+
+    // Generate a random number.
+    $nonce = md5(mt_rand());
+
+    // Calculate a HMAC-SHA1 according to RFC 2104.
+    // @see http://www.ietf.org/rfc/rfc2104.txt
+    $request_data = $time . ':' . $nonce . ':' . $this->privateKey;
+    $hash = base64_encode(hash_hmac('sha1', $request_data, $this->privateKey, TRUE));
+
+    // Return HMAC authentication parameters as a keyed array.
+    $data['publicKey'] = $this->publicKey;
+    $data['time'] = $time;
+    $data['hash'] = $hash;
+    $data['nonce'] = $nonce;
+
+    return $data;
+  }
+
+  /**
+   * Fetches Mollom servers from local configuration or retrieves a new list.
+   *
+   * @return
+   *   An indexed array of Mollom servers, which are also assigned to
+   *   $this->servers.
+   *
+   * @see Mollom::refreshServers()
+   */
+  public function getServers() {
+    // If there is no server list yet, consult the local configuration.
+    if (empty($this->servers)) {
+      $servers = $this->loadConfiguration('servers');
+      // Use the local configuration value, if any.
+      if (!empty($servers) && is_array($servers)) {
+        $this->servers = $servers;
+      }
+      // Otherwise, retrieve a new server list from Mollom.
+      else {
+        $this->servers = $this->refreshServers();
+        if ($this->servers) {
+          $this->saveConfiguration('servers', $this->servers);
+          $this->log[] = array(
+            'type' => 'debug',
+            'message' => 'Refreshed servers: %servers',
+            'arguments' => array(
+              '%servers' => implode(', ', $this->servers),
+            ),
+          );
+        }
+      }
+    }
+    return $this->servers;
+  }
+
+  /**
+   * Returns a new server list retrieved from Mollom.
+   *
+   * The server list returned from Mollom should be stored as configuration
+   * value on the client-side. This method should only be called when the
+   * configuration value is not yet or no longer available, or when a Mollom
+   * server specifically asks the client to refresh its server list.
+   *
+   * @return array
+   *   An indexed array of server URLs retrieved from Mollom.
+   *
+   * @see Mollom::getServers()
+   * @see Mollom::query()
+   * @see MollomRefreshException
+   * @see Mollom::REFRESH_ERROR
+   */
+  protected function refreshServers() {
+    // refreshServers() cannot use query() as we need to prevent infinite
+    // recursion. In addition, we handle returned error codes differently here,
+    // since REDIRECT_ERROR, REFRESH_ERROR, and any other communication error
+    // requires us to skip to the next server in order to retrieve a new server
+    // list. We only ever abort, if we get an AUTH_ERROR, in which case there
+    // is a configuration error (i.e., invalid API keys).
+    $servers = array();
+    try {
+      $data = $this->getAuthentication();
+    }
+    catch (MollomAuthenticationException $e) {
+      return $servers;
+    }
+
+    $path = 'site/' . $this->publicKey;
+    $expected = array('site', 'servers');
+    foreach ($this->serversInit as $server) {
+      try {
+        $result = $this->request('GET', $server . '/' . self::API_VERSION, $path, $data, $expected);
+      }
+      catch (MollomAuthenticationException $e) {
+        // Bogus configuration. Stop trying, since all servers will fail.
+        break;
+      }
+      catch (MollomException $e) {
+        // On any other error, skip to the next server.
+        continue;
+      }
+
+      if (isset($result) && $this->lastResponseCode === TRUE) {
+        $servers = $result['site']['servers'];
+        break;
+      }
+    }
+    return $servers;
+  }
+
+  /**
+   * Retrieve or send data from/to Mollom servers.
+   *
+   * @param string $method
+   *   The HTTP method to use; i.e., 'GET', 'POST', or 'PUT'.
+   * @param string $path
+   *   The REST path/resource to request; e.g., 'site/1a2b3c'.
+   * @param array $data
+   *   An associative array of query parameters to send with the request.
+   * @param array $expected
+   *   (optional) An element that is expected in the response, denoted as a list
+   *   of parent element keys to the element and the element key itself; e.g., a
+   *   value of array('site', 'servers') expects $response['site']['servers'] to
+   *   exist in the response.
+   *
+   * @return mixed
+   *   On success, the parsed response body. On failure, the last response code,
+   *   in case it is a known one; otherwise Mollom::NETWORK_ERROR.
+   *
+   * @see Mollom::request()
+   */
+  protected function query($method, $path, array $data = array(), array $expected = array()) {
+    // Retrieve server list.
+    // If we get no list, we will have no servers to iterate over and only the
+    // error logic remains.
+    $this->getServers();
+
+    // Unconditionally add HMAC request authentication parameters.
+    try {
+      $data += $this->getAuthentication();
+    }
+    catch (MollomAuthenticationException $e) {
+      // MollomAuthenticationException sets the appropriate error code. Merely
+      // catch the exception, move on to error logic below.
+      // Prevent any requests from being performed in case we have servers
+      // loaded from configuration or due to some other edge-case (scripting).
+      $this->servers = array();
+    }
+
+    // Initialize refresh variable.
+    $refresh = FALSE;
+    // Send the request to the first server; if that fails, try the other
+    // servers in the list.
+    // @todo The Mollom instance "sticks" now, as it's statically cached. Thus,
+    //   the array cursor in $this->servers is retained across multiple queries.
+    //   I.e., subsequent queries will continue to communicate with the
+    //   "current" server, which might not be the first.
+    //   PRO: In a scenario that uses the statically cached class instance
+    //   (low-level scripts and possibly subsequent form submissions in the
+    //   future) and in which a server redirects, subsequent queries will
+    //   continue to use the current/second/next server in the list instead of
+    //   restarting on the first; i.e., potentially less "wasted" requests.
+    //   CON: Higher chance to reach the end of the server list, unless we allow
+    //   to iterate two times over the server list until we consider a request
+    //   to fail.
+    while ($server = current($this->servers)) {
+      try {
+        $result = $this->request($method, $server, $path, $data, $expected);
+      }
+      catch (MollomRefreshException $e) {
+        // Prevent infinite loops.
+        if (!$refresh) {
+          $refresh = TRUE;
+
+          // In any case, the current server list is no longer valid.
+          $this->servers = array();
+          $this->deleteConfiguration('servers');
+
+          // Retrieve a fresh list of Mollom servers.
+          $this->servers = $this->getServers();
+          // If API keys are invalid, we won't be able to get a new server list.
+          // To reach this, we must have had a server list (and therefore
+          // valid keys) before, so we do not immediately return, but trigger
+          // the fallback mode instead.
+          if (empty($this->servers)) {
+            break;
+          }
+        }
+      }
+      catch (MollomRedirectException $e) {
+        // Try the next server in the list.
+        $next = next($this->servers);
+
+        // @todo $next may be FALSE, confusing users looking into logs.
+        $this->log[] = array(
+          'type' => 'debug',
+          'message' => 'Server %server redirected to %next.',
+          'arguments' => array(
+            '%server' => $server,
+            '%next' => $next,
+          ),
+        );
+        continue;
+      }
+      catch (MollomAuthenticationException $e) {
+        // This is an irrecoverable error, so don't try other servers.
+        break;
+      }
+      catch (MollomException $e) {
+        // If the resource does not exist, there is no point in trying another
+        // server.
+        if ($e->getCode() == 404) {
+          break;
+        }
+        // On any other known error, try the next server.
+        next($this->servers);
+        continue;
+      }
+
+      // Unless we have a positive result, continue to next server.
+      if ($this->lastResponseCode === TRUE) {
+        break;
+      }
+      else {
+        next($this->servers);
+      }
+    }
+
+    // In case all servers failed, reset the server list to enforce retrieval of
+    // a new list the next time.
+    if (current($this->servers) === FALSE) {
+      $this->servers = array();
+      $this->deleteConfiguration('servers');
+
+      $this->log[] = array(
+        'type' => 'error',
+        'message' => 'All servers unreachable or returning errors. The server list was emptied: %servers',
+        'arguments' => array(
+          '%servers' => implode(', ', $this->servers ? $this->servers : $this->serversInit),
+        ),
+      );
+    }
+
+    // Write all captured log messages.
+    $this->writeLog();
+
+    // If there is a result (only possible with a server list) and the last
+    // request succeeded, return the result to the caller.
+    if (isset($result) && $this->lastResponseCode === TRUE) {
+      return $result;
+    }
+    // If the last request succeeded but there was a unexpected response, return
+    // the error code.
+    if ($this->lastResponseCode == self::RESPONSE_ERROR) {
+      return $this->lastResponseCode;
+    }
+    // Return an authentication error, which may require special client-side
+    // processing.
+    if ($this->lastResponseCode == self::AUTH_ERROR) {
+      return $this->lastResponseCode;
+    }
+    // Return a not found error, which always needs to be handled by the calling
+    // code.
+    if ($this->lastResponseCode == 404) {
+      return $this->lastResponseCode;
+    }
+
+    // In case of any kind of HTTP error (404, 0 [invalid-address],
+    // -1002 [bad URI], etc), return a generic NETWORK_ERROR.
+    return self::NETWORK_ERROR;
+  }
+
+  /**
+   * Performs a HTTP request to a Mollom server.
+   *
+   * @param string $method
+   *   The HTTP method to use; i.e., 'GET', 'POST', or 'PUT'.
+   * @param string $server
+   *   The base URL of the server to perform the request against; e.g.,
+   *   'http://foo.mollom.com'.
+   * @param string $path
+   *   The REST path/resource to request; e.g., 'site/1a2b3c'.
+   * @param array $data
+   *   An associative array of query parameters to send with the request.
+   * @param array $expected
+   *   (optional) An element that is expected in the response, denoted as a list
+   *   of parent element keys to the element and the element key itself; e.g., a
+   *   value of array('site', 'servers') expects $response['site']['servers'] to
+   *   exist in the response.
+   *
+   * @throws MollomNetworkException
+   * @throws MollomAuthenticationException
+   * @throws MollomRedirectException
+   * @throws MollomRefreshException
+   * @throws MollomException
+   *
+   * @see Mollom::query()
+   * @see Mollom::httpBuildQuery()
+   * @see Mollom::httpParseQuery()
+   * @see Mollom::parseXML()
+   * @see json_decode()
+   */
+  abstract protected function request($method, $server, $path, array $data, array $expected = array());
+
+  /**
+   * Converts a SimpleXMLIterator structure into an associative array.
+   *
+   * Used to parse an XML response from Mollom servers into a PHP array. For
+   * example:
+   * @code
+   * $elements = new SimpleXmlIterator($response_body);
+   * $parsed_response = $this->parseXML($elements);
+   * @endcode
+   *
+   * @param SimpleXMLIterator $sxi
+   *   A SimpleXMLIterator structure of the server response body.
+   *
+   * @return array
+   *   An associative, possibly multidimensional array.
+   */
+  public static function parseXML(SimpleXMLIterator $sxi) {
+    $a = array();
+    $remove = array();
+    for ($sxi->rewind(); $sxi->valid(); $sxi->next()) {
+      $key = $sxi->key();
+
+      // Recurse into non-scalar values.
+      if ($sxi->hasChildren()) {
+        $value = self::parseXML($sxi->current());
+      }
+      // Use a simple key/value pair for scalar values.
+      else {
+        $value = strval($sxi->current());
+      }
+
+      if (!isset($a[$key])) {
+        $a[$key] = $value;
+      }
+      // Convert already existing keys into indexed keys, retaining other
+      // existing keys in the array; i.e., two or more XML elements of the
+      // same name and on the same level.
+      // Note that this XML to PHP array conversion does not support multiple
+      // different elements that each appear multiple times.
+      else {
+        // First time we reach here, convert the existing keyed item. Do not
+        // remove $key, so we enter this path again.
+        if (!isset($remove[$key])) {
+          $a[] = $a[$key];
+          // Mark $key for removal.
+          $remove[$key] = $key;
+        }
+        // Add the new item.
+        $a[] = $value;
+      }
+    }
+    // Lastly, remove named keys that have been converted to indexed keys.
+    foreach ($remove as $key) {
+      unset($a[$key]);
+    }
+    return $a;
+  }
+
+  /**
+   * Determines whether a nested array with variable depth contains all of the requested keys.
+   *
+   * @param array $array
+   *   The array with variable depth that may contain the value to check for.
+   * @param array $expected
+   *   A list of parent keys of the value, starting with the outermost key.
+   *
+   * @return bool
+   *   TRUE if all the parent keys exist, FALSE otherwise.
+   *
+   * @see Mollom::request()
+   */
+  public static function arrayKeyExists(array $array, array $expected) {
+    $ref = &$array;
+    foreach ($expected as $parent) {
+      if (is_array($ref) && array_key_exists($parent, $ref)) {
+        $ref = &$ref[$parent];
+      }
+      else {
+        return FALSE;
+      }
+    }
+    return TRUE;
+  }
+
+  /**
+   * Builds an RFC-compliant, rawurlencoded query string.
+   *
+   * PHP did a design decision to only support HTTP query parameters in the form
+   * of foo[]=1&foo[]=2, primarily for its built-in and automated conversion to
+   * PHP arrays. Other platforms (including the Mollom backend) do not support
+   * this syntax and expect multiple parameters to be in the form of
+   * foo=1&foo=2.
+   *
+   * @see http_build_query()
+   * @see http://en.wikipedia.org/wiki/Query_string
+   * @see http://tools.ietf.org/html/rfc3986#section-3.4
+   *
+   * @param array $query
+   *   The query parameter array to be processed, e.g. $_GET.
+   * @param string $parent
+   *   Internal use only. Used to build the $query array key for nested items.
+   *
+   * @return string
+   *   A rawurlencoded string which can be used as or appended to the URL query
+   *   string.
+   *
+   * @see Mollom::httpParseQuery()
+   */
+  public static function httpBuildQuery(array $query, $parent = '') {
+    $params = array();
+
+    foreach ($query as $key => $value) {
+      // For indexed (unnamed) child array keys, use the same parameter name,
+      // leading to param=foo&param=bar instead of param[]=foo&param[]=bar.
+      if ($parent && is_int($key)) {
+        $key = rawurlencode($parent);
+      }
+      else {
+        $key = ($parent ? $parent . '[' . rawurlencode($key) . ']' : rawurlencode($key));
+      }
+
+      // Recurse into children.
+      if (is_array($value)) {
+        $params[] = self::httpBuildQuery($value, $key);
+      }
+      // If a query parameter value is NULL, only append its key.
+      elseif (!isset($value)) {
+        $params[] = $key;
+      }
+      else {
+        // For better readability of paths in query strings, we decode slashes.
+        $params[] = $key . '=' . str_replace('%2F', '/', rawurlencode($value));
+      }
+    }
+
+    return implode('&', $params);
+  }
+
+  /**
+   * Parses an RFC-compliant, rawurlencoded query string.
+   *
+   * Mollom clients normally do not need this function, as they do not need to
+   * process requests from a server - unless a client attempts to implement
+   * client-side unit testing.
+   *
+   * @param string $query
+   *   The query parameter string to process, e.g. $_SERVER['QUERY_STRING']
+   *   (GET) or php://input (POST/PUT).
+   *
+   * @return array
+   *   A query parameter array parsed from $query.
+   *
+   * @see Mollom::httpBuildQuery()
+   * @see parse_str()
+   */
+  public static function httpParseQuery($query) {
+    if ($query === '') {
+      return array();
+    }
+    // Explode parameters into arrays to check for duplicate names.
+    $params = array();
+    $seen = array();
+    $duplicate = array();
+    foreach (explode('&', $query) as $chunk) {
+      $param = explode('=', $chunk, 2);
+      if (isset($seen[$param[0]])) {
+        $duplicate[$param[0]] = TRUE;
+      }
+      $seen[$param[0]] = TRUE;
+      $params[] = $param;
+    }
+    // Implode back into a string.
+    $query = '';
+    foreach ($params as $param) {
+      $query .= $param[0];
+      if (isset($duplicate[$param[0]])) {
+        $query .= '[]';
+      }
+      if (isset($param[1])) {
+        $query .= '=' . $param[1];
+      }
+      $query .= '&';
+    }
+    // Parse query string as usual.
+    parse_str($query, $result);
+    return $result;
+  }
+
+  /**
+   * Retrieves a list of sites accessible to this client.
+   *
+   * Used by Mollom resellers only.
+   *
+   * @return array
+   *   An array containing site resources, as returned by Mollom::getsite().
+   */
+  public function getSites() {
+    $result = $this->query('GET', 'site', array(), array('list'));
+    // In XML, 'list' is a string when blacklist is empty.
+    // @todo Move into query().
+    if (isset($result['list'])) {
+      // parseXML() can only convert multiple sub-elements into an indexed array.
+      if (is_array($result['list'])) {
+        $result['list'] = array_values($result['list']);
+        return $result['list'];
+      }
+      return array();
+    }
+    return $result;
+  }
+
+  /**
+   * Retrieves information about a site.
+   *
+   * @param string $publicKey
+   *   (optional) The public Mollom API key of the site to retrieve. Defaults to
+   *   the public key of the client.
+   *
+   * @return mixed
+   *   On success, an associative array containing:
+   *   - publicKey: The public Mollom API key of the site.
+   *   - privateKey: The private Mollom API key of the site.
+   *   - url: The URL of the site.
+   *   - email: The e-mail address of the primary contact of the site.
+   *   - languages: (optional) An array of language ISO codes, content is
+   *     expected to submitted in on the site.
+   *   - platformName: (optional) The name of the platform running the site
+   *     (e.g., "Drupal").
+   *   - platformVersion: (optional) The version of the platform running the
+   *     site (e.g., "6.20").
+   *   - clientName: (optional) The name of the Mollom client plugin used
+   *     (e.g., "Mollom").
+   *   - clientVersion: (optional) The version of the Mollom client plugin used
+   *     (e.g., "6.15").
+   *   On failure, the error response code returned by the server.
+   */
+  public function getSite($publicKey = NULL) {
+    if (!isset($publicKey)) {
+      $publicKey = $this->publicKey;
+    }
+    $result = $this->query('GET', 'site/' . $publicKey, array(), array('site'));
+    return isset($result['site']) ? $result['site'] : $result;
+  }
+
+  /**
+   * Creates a new site.
+   *
+   * @param array $data
+   *   An associative array of properties for the new site. At least 'url' and
+   *   'email' are required. See Mollom::getSite() for details.
+   *
+   * @return mixed
+   *   On success, the full site information of the created site; see
+   *   Mollom::getSite() for details. On failure, the error response code
+   *   returned by the server. Or FALSE if 'url' or 'email' was not specified.
+   */
+  public function createSite(array $data = array()) {
+    if (empty($data['url']) || empty($data['email'])) {
+      return FALSE;
+    }
+    $result = $this->query('POST', 'site', $data, array('site'));
+    return isset($result['site']) ? $result['site'] : $result;
+  }
+
+  /**
+   * Updates a site to verify API keys and send client information.
+   *
+   * @return mixed
+   *   TRUE on success. On failure, the error response code returned by the
+   *   server; one of Mollom::AUTH_ERROR or Mollom::NETWORK_ERROR.
+   */
+  public function verifyKey() {
+    $data = $this->getClientInformation();
+    $result = $this->query('POST', 'site/' . $this->publicKey, $data, array('site'));
+    // If the public key could not be found, make sure to return an
+    // authentication error.
+    if ($this->lastResponseCode === 404) {
+      return self::AUTH_ERROR;
+    }
+    // lastResponseCode will either be TRUE, AUTH_ERROR, or NETWORK_ERROR.
+    return $this->lastResponseCode === TRUE ? TRUE : $this->lastResponseCode;
+  }
+
+  /**
+   * Deletes a site.
+   *
+   * @param string $publicKey
+   *   The public Mollom API key of the site to delete.
+   *
+   * @return bool
+   *   TRUE on success, FALSE otherwise.
+   */
+  public function deleteSite($publicKey) {
+    $result = $this->query('POST', 'site/' . $publicKey . '/delete');
+    return $this->lastResponseCode === TRUE;
+  }
+
+  /**
+   * Checks user-submitted content with Mollom.
+   *
+   * @param array $data
+   *   An associative array containing any of the keys:
+   *   - id: The existing content ID of the content, if it or a variant or
+   *     revision of it has been checked before.
+   *   - postTitle: The title of the content.
+   *   - postBody: The body of the content. If the content consists of multiple
+   *     fields, concatenate them into one postBody string, separated by " \n"
+   *     (space and line-feed).
+   *   - authorName: The (real) name of the content author.
+   *   - authorUrl: The homepage/website URL of the content author.
+   *   - authorMail: The e-mail address of the content author.
+   *   - authorIp: The IP address of the content author.
+   *   - authorId: The local user ID on the client site of the content author.
+   *   - authorOpenid: An indexed array of Open IDs of the content author.
+   *   - checks: An indexed array of strings denoting the checks to perform, one
+   *     or more of: 'spam', 'quality', 'profanity', 'language', 'sentiment'.
+   *     Defaults to 'spam'.
+   *   - unsure: Integer denoting whether a "unsure" response should be allowed
+   *     (1) for the 'spam' check (which should lead to CAPTCHA) or not (0).
+   *     Defaults to 1.
+   *   - strictness: A string denoting the strictness of Mollom checks to
+   *     perform; one of 'strict', 'normal', or 'relaxed'. Defaults to 'normal'.
+   *   - rateLimit: Seconds that must have passed by for the same author to post
+   *     again. Defaults to 15.
+   *   - honeypot: The value of a client-side honeypot form element, if
+   *     non-empty.
+   *   - ttl: Time-to-live in seconds for the content. Should be set to a
+   *     reasonable small amount of seconds during form validation, until form
+   *     validation passed. Defaults to -1 (forever).
+   *
+   * @return mixed
+   *   On success, an associative array representing the full content record,
+   *   containing the additional keys:
+   *   - spamScore: A floating point value with a precision of 2, ranging
+   *     between 0.00 and 1.00; whereas 0.00 denotes 100% spam, 0.50 denotes
+   *     "unsure", and 1.00 denotes ham. Only returned if 'spam' was passed for
+   *     'checks'.
+   *   - spamClassification: The final spam classification; one of 'spam',
+   *     'unsure', or 'ham'. Only returned if 'spam' was passed for 'checks'.
+   *   - profanityScore: A floating point value with a precision of 2, ranging
+   *     between 0.00 and 1.00; whereas 0.00 denotes 0% profanity and 1.00
+   *     denotes 100% profanity. Only returned if 'profanity' was passed for
+   *     'checks'.
+   *   - qualityScore: A floating point value with a precision of 2, ranging
+   *     between 0.00 and 1.00; whereas 0.00 denotes poor quality and 1.00
+   *     high quality. Only returned if 'quality' was passed for 'checks'.
+   *   - sentimentScore: A floating point value with a precision of 2, ranging
+   *     between 0.00 and 1.00; whereas 0.00 denotes bad sentiment and 1.00
+   *     good sentiment. Only returned if 'sentiment' was passed for 'checks'.
+   *   - reason: A string denoting the reason for Mollom's classification; e.g.,
+   *     - rateLimit: Author was seen on Mollom-protected sites within the given
+   *       'rateLimit' time-frame.
+   *   On failure, the error response code returned by the server.
+   */
+  public function checkContent(array $data = array()) {
+    $path = 'content';
+    if (!empty($data['id'])) {
+      $path .= '/' . $data['id'];
+    }
+    $result = $this->query('POST', $path, $data, array('content', 'id'));
+
+    // parseXML() can only convert multiple sub-elements into an indexed array.
+    if (isset($result['content']['languages']) && is_array($result['content']['languages'])) {
+      $result['content']['languages'] = array_values($result['content']['languages']);
+    }
+
+    return isset($result['content']) ? $result['content'] : $result;
+  }
+
+  /**
+   * Retrieves a CAPTCHA resource from Mollom.
+   *
+   * @param array $data
+   *   An associative array containing:
+   *   - type: A string denoting the type of CAPTCHA to create; one of 'image'
+   *     or 'audio'.
+   *   and any of the keys:
+   *   - contentId: The ID of a content resource to link the CAPTCHA to. Allows
+   *     Mollom to learn when it was unsure.
+   *   - ssl: An integer denoting whether to create a CAPTCHA URL using HTTPS
+   *     (1) or not (0). Only available for paid subscriptions.
+   *
+   * @return mixed
+   *   On success, an associative array representing the full CAPTCHA record,
+   *   containing:
+   *   - id: The ID of the CAPTCHA.
+   *   - url: The URL of the CAPTCHA.
+   *   On failure, the error response code returned by the server.
+   *
+   * @todo Rename to createCaptcha().
+   */
+  public function getCaptcha(array $data = array()) {
+    if (!isset($data['type']) || !in_array($data['type'], array('image', 'audio'))) {
+      // @todo Public method should not throw a MollomException.
+      throw new MollomException('Unknown CAPTCHA type.', 0, NULL, $this);
+    }
+    $path = 'captcha';
+    $result = $this->query('POST', $path, $data, array('captcha', 'id'));
+
+    return isset($result['captcha']) ? $result['captcha'] : $result;
+  }
+
+  /**
+   * Checks whether a user-submitted solution for a CAPTCHA is correct.
+   *
+   * @param array $data
+   *   An associative array containing:
+   *   - id: The ID of the CAPTCHA to check.
+   *   - solution: The answer provided by the author.
+   *   and any of the keys:
+   *   - authorName: The (real) name of the content author.
+   *   - authorUrl: The homepage/website URL of the content author.
+   *   - authorMail: The e-mail address of the content author.
+   *   - authorIp: The IP address of the content author.
+   *   - authorId: The local user ID on the client site of the content author.
+   *   - authorOpenid: An indexed array of Open IDs of the content author.
+   *   - rateLimit: Seconds that must have passed by for the same author to post
+   *     again. Defaults to 15.
+   *   - honeypot: The value of a client-side honeypot form element, if
+   *     non-empty.
+   *
+   * @return mixed
+   *   On success, an associative array representing the full CAPTCHA record,
+   *   additionally containing:
+   *   - solved: Whether the provided solution was correct (1) or not (0).
+   *   - reason: A string denoting the reason for Mollom's classification; e.g.,
+   *     - rateLimit: Author was seen on Mollom-protected sites within the given
+   *       'rateLimit' time-frame.
+   *   On failure, the error response code returned by the server.
+   *
+   * @todo Rename to createCaptcha().
+   */
+  public function checkCaptcha(array $data = array()) {
+    if (empty($data['id'])) {
+      // @todo Public method should not throw a MollomException.
+      throw new MollomException('Missing CAPTCHA ID.', 0, NULL, $this);
+    }
+    $result = $this->query('POST', 'captcha/' . $data['id'], $data, array('captcha', 'id'));
+
+    return isset($result['captcha']) ? $result['captcha'] : $result;
+  }
+
+  /**
+   * Sends feedback to Mollom.
+   *
+   * @param array $data
+   *   An associative array containing:
+   *   - reason: A string denoting the reason for why the content associated
+   *     with either contentId or captchaId is being reported; one of:
+   *     - spam: The content is spam, unsolicited advertising.
+   *     - profanity: The content contains obscene, violent, profane language.
+   *     - quality: The content is of low quality.
+   *     - unwanted: The content is unwanted, taunting, off-topic.
+   *   and at least one of:
+   *   - contentId: A Mollom content ID associated with the content.
+   *   - captchaId: A Mollom CAPTCHA ID associated with the content.
+   *
+   * @return bool
+   *   TRUE if the feedback was sent successfully, FALSE otherwise.
+   */
+  public function sendFeedback(array $data) {
+    if (empty($data['contentId']) && empty($data['captchaId'])) {
+      // @todo Public method should not throw a MollomException.
+      throw new MollomException('Missing resource ID.', 0, NULL, $this);
+    }
+    if (empty($data['reason'])) {
+      // @todo Public method should not throw a MollomException.
+      throw new MollomException('Missing feedback reason.', 0, NULL, $this);
+    }
+    $this->query('POST', 'feedback', $data);
+    return $this->lastResponseCode === TRUE ? TRUE : FALSE;
+  }
+
+  /**
+   * Retrieves the blacklist for a site.
+   *
+   * @param string $publicKey
+   *   (optional) The public Mollom API key of the site to retrieve the
+   *   blacklist for. Defaults to the public key of the client.
+   *
+   * @return mixed
+   *   An array containing blacklist entries; see Mollom::getBlacklistEntry()
+   *   for details. On failure, the error response code returned by the server.
+   *
+   * @todo List parameters.
+   */
+  public function getBlacklist($publicKey = NULL) {
+    if (!isset($publicKey)) {
+      $publicKey = $this->publicKey;
+    }
+    $result = $this->query('GET', 'blacklist/' . $publicKey, array(), array('list'));
+    // In XML, 'list' is a string when blacklist is empty.
+    // @todo Move into query().
+    if (isset($result['list'])) {
+      // parseXML() can only convert multiple sub-elements into an indexed array.
+      if (is_array($result['list'])) {
+        $result['list'] = array_values($result['list']);
+        return $result['list'];
+      }
+      return array();
+    }
+    return $result;
+  }
+
+  /**
+   * Retrieves a blacklist entry stored for a site.
+   *
+   * @param string $entryId
+   *   The ID of the blacklist entry to retrieve.
+   * @param string $publicKey
+   *   (optional) The public Mollom API key of the site to retrieve the
+   *   blacklist entry for. Defaults to the public key of the client.
+   *
+   * @return mixed
+   *   On success, an associative array containing:
+   *   - id: The ID the of blacklist entry.
+   *   - created: A timestamp in seconds since the UNIX epoch of when the entry
+   *     was created.
+   *   - value: The blacklisted string/value.
+   *   - reason: A string denoting the reason for why the term is blacklisted;
+   *     one of 'spam', 'profanity', 'quality', or 'unwanted'. Defaults to
+   *     'unwanted'.
+   *   - context: A string denoting where the entry's value may match; one of
+   *     'allFields', 'ip', 'email', 'links', 'authorIp', 'title'. Defaults to
+   *     'allFields'.
+   *   - match: A string denoting how precise the entry's value may match; one
+   *     of 'exact' or 'contains'. Defaults to 'contains'.
+   *   - status: An integer denoting whether the entry is enabled (1) or not
+   *     (0).
+   *   - note: A custom string explaining the entry. Useful when disabling
+   *     entries in a multi-moderator scenario.
+   *   - lastMatch: A timestamp in seconds since the UNIX epoch of when the entry
+   *     was last matched in a content.
+   *   - matchCount: An integer denoting how many times the entry was matched in
+   *     content.
+   *   On failure, the error response code returned by the server.
+   */
+  public function getBlacklistEntry($entryId, $publicKey = NULL) {
+    if (!isset($publicKey)) {
+      $publicKey = $this->publicKey;
+    }
+    $result = $this->query('GET', 'blacklist/' . $publicKey . '/' . $entryId, array(), array('entry', 'id'));
+    return isset($result['entry']) ? $result['entry'] : $result;
+  }
+
+  /**
+   * Creates a new blacklist entry for a site.
+   *
+   * @param array $data
+   *   An associative array specifying the blacklist entry to create. See return
+   *   value of Mollom::getBlacklistEntry() for details.
+   * @param string $publicKey
+   *   (optional) The public Mollom API key of the site to create the blacklist
+   *   entry for. Defaults to the public key of the client.
+   *
+   * @return mixed
+   *   On success, the full blacklist entry record of the created entry; see
+   *   Mollom::getBlacklistEntry() for details. On failure, the error response
+   *   code returned by the server.
+   *
+   * @todo Combine into saveBlacklistEntry().
+   */
+  public function createBlacklistEntry(array $data = array(), $publicKey = NULL) {
+    if (!isset($publicKey)) {
+      $publicKey = $this->publicKey;
+    }
+    $result = $this->query('POST', 'blacklist/' . $publicKey, $data, array('entry', 'id'));
+    return isset($result['entry']) ? $result['entry'] : $result;
+  }
+
+  public function updateBlacklistEntry(array $data = array(), $publicKey = NULL) {
+    if (empty($data['id'])) {
+      // @todo Public method should not throw a MollomException.
+      throw new MollomException('Missing blacklist entry ID.', 0, NULL, $this);
+    }
+    if (!isset($publicKey)) {
+      $publicKey = $this->publicKey;
+    }
+    $result = $this->query('POST', 'blacklist/' . $publicKey . '/' . $data['id'], $data, array('entry', 'id'));
+
+    return isset($result['entry']) ? $result['entry'] : $result;
+  }
+
+  /**
+   * Deletes a blacklist entry from a site.
+   *
+   * @param string $entryId
+   *   The ID of the blacklist entry to delete.
+   * @param string $publicKey
+   *   (optional) The public Mollom API key of the site to create the blacklist
+   *   entry for. Defaults to the public key of the client.
+   *
+   * @return bool
+   *   TRUE on success, FALSE otherwise.
+   */
+  public function deleteBlacklistEntry($entryId, $publicKey = NULL) {
+    if (!isset($publicKey)) {
+      $publicKey = $this->publicKey;
+    }
+    $result = $this->query('POST', 'blacklist/' . $publicKey . '/' . $entryId . '/delete');
+    return $this->lastResponseCode === TRUE;
+  }
+}
+
diff --git mollom.admin.inc mollom.admin.inc
index b0e96be..42b4cea 100644
--- mollom.admin.inc
+++ mollom.admin.inc
@@ -444,12 +444,13 @@ function mollom_admin_blacklist_form($form, &$form_state, $type = 'spam') {
 
   $form['blacklist'] = array();
   // Do not retrieve the current blacklist when submitting the form.
-  $blacklist = (empty($form_state['input']) ? mollom('mollom.listBlacklistText') : array());
+  $blacklist = (empty($form_state['input']) ? mollom()->getBlacklist() : array());
   if (is_array($blacklist)) {
-    foreach ($blacklist as $id => $entry) {
+    foreach ($blacklist as $entry) {
       if ($entry['reason'] != $type) {
         continue;
       }
+      $id = $entry['id'];
       // #class property is internally used by
       // theme_mollom_admin_blacklist_form().
       $row = array(
@@ -461,21 +462,17 @@ function mollom_admin_blacklist_form($form, &$form_state, $type = 'spam') {
           '#markup' => check_plain($matches[$entry['match']]),
           '#class' => 'mollom-blacklist-match value-' . check_plain($entry['match']),
         ),
-        'text' => array(
-          '#markup' => check_plain($entry['text']),
-          '#class' => 'mollom-blacklist-text',
+        'value' => array(
+          '#markup' => check_plain($entry['value']),
+          '#class' => 'mollom-blacklist-value',
         ),
       );
       $row['actions']['delete'] = array(
         '#type' => 'link',
         '#title' => t('delete'),
-        '#href' => 'admin/config/content/mollom/blacklist/delete',
+        '#href' => 'admin/config/content/mollom/blacklist/delete/' . $entry['id'],
         '#options' => array(
-          'query' => array(
-            'text' => $entry['text'],
-            'context' => $entry['context'],
-            'reason' => $entry['reason'],
-          ) + drupal_get_destination(),
+          'query' => drupal_get_destination(),
         ),
       );
       $form['blacklist'][$id] = $row;
@@ -501,14 +498,14 @@ function mollom_admin_blacklist_form($form, &$form_state, $type = 'spam') {
     '#required' => TRUE,
     '#id' => 'mollom-blacklist-filter-match',
   );
-  $form['entry']['text'] = array(
+  $form['entry']['value'] = array(
     '#type' => 'textfield',
-    '#title' => t('Text'),
+    '#title' => t('Value'),
     '#title_display' => 'invisible',
     '#size' => 40,
     '#required' => TRUE,
     '#maxlength' => 64,
-    '#id' => 'mollom-blacklist-filter-text',
+    '#id' => 'mollom-blacklist-filter-value',
     '#attributes' => array(
       'autocomplete' => 'off',
     ),
@@ -534,31 +531,31 @@ function mollom_admin_blacklist_form($form, &$form_state, $type = 'spam') {
  */
 function mollom_admin_blacklist_form_submit($form, &$form_state) {
   $data = array(
-    'text' => $form_state['values']['entry']['text'],
+    'value' => $form_state['values']['entry']['value'],
     'context' => $form_state['values']['entry']['context'],
     'match' => $form_state['values']['entry']['match'],
     'reason' => $form_state['values']['entry']['reason'],
   );
-  $result = mollom('mollom.addBlacklistText', $data);
+  $result = mollom()->createBlacklistEntry($data);
 
   $args = array(
-    '@text' => $data['text'],
+    '@value' => $data['value'],
     '@context' => $data['context'],
     '@match' => $data['match'],
     '@reason' => $data['reason'],
   );
-  if ($result === TRUE) {
+  if (!empty($result['id'])) {
     drupal_set_message(t('The entry was added to the blacklist.'));
     _mollom_watchdog(array(
-      'Added @text (@context, @match) to @reason blacklist.' => $args,
+      'Added @value (@context, @match) to @reason blacklist.' => $args,
       'Data:<pre>@data</pre>' => array('@data' => $data),
       'Result:<pre>@result</pre>' => array('@result' => $result),
     ));
   }
   else {
-    drupal_set_message(t('An error occurred upon trying to add the text to the blacklist.'), 'error');
+    drupal_set_message(t('An error occurred upon trying to add the value to the blacklist.'), 'error');
     _mollom_watchdog(array(
-      'Failed to add @text (@context, @match) to @reason blacklist.' => $args,
+      'Failed to add @value (@context, @match) to @reason blacklist.' => $args,
       'Data:<pre>@data</pre>' => array('@data' => $data),
       'Result:<pre>@result</pre>' => array('@result' => $result),
     ), WATCHDOG_ERROR);
@@ -573,7 +570,7 @@ function theme_mollom_admin_blacklist_form($variables) {
   $header = array(
     t('Context'),
     t('Matches'),
-    t('Text'),
+    t('Value'),
     '',
   );
   $rows = array();
@@ -581,7 +578,7 @@ function theme_mollom_admin_blacklist_form($variables) {
   $rows[] = array(
     drupal_render($form['entry']['context']),
     drupal_render($form['entry']['match']),
-    drupal_render($form['entry']['text']),
+    drupal_render($form['entry']['value']),
     drupal_render($form['entry']['actions']),
   );
 
@@ -596,8 +593,8 @@ function theme_mollom_admin_blacklist_form($variables) {
         'class' => $form['blacklist'][$id]['match']['#class'],
       ),
       array(
-        'data' => drupal_render($form['blacklist'][$id]['text']),
-        'class' => $form['blacklist'][$id]['text']['#class'],
+        'data' => drupal_render($form['blacklist'][$id]['value']),
+        'class' => $form['blacklist'][$id]['value']['#class'],
       ),
       drupal_render($form['blacklist'][$id]['actions']),
     );
@@ -625,23 +622,16 @@ function theme_mollom_admin_blacklist_form($variables) {
  * @ingroup forms
  * @see mollom_admin_blacklist_delete_submit()
  */
-function mollom_admin_blacklist_delete($form, &$form_state) {
-  $form['text'] = array(
-    '#type' => 'value',
-    '#value' => $_GET['text'],
-  );
-  $form['context'] = array(
+function mollom_admin_blacklist_delete($form, &$form_state, $entryId) {
+  $entry = mollom()->getBlacklistEntry($entryId);
+  $form['entry'] = array(
     '#type' => 'value',
-    '#value' => $_GET['context'],
-  );
-  $form['reason'] = array(
-    '#type' => 'value',
-    '#value' => $_GET['reason'],
+    '#value' => $entry,
   );
 
   return confirm_form(
     $form,
-    t('Are you sure you want to delete %text from the blacklist?', array('%text' => $_GET['text'])),
+    t('Are you sure you want to delete %value from the blacklist?', array('%value' => $entry['value'])),
     'admin/config/content/mollom/blacklist',
     t('This action cannot be undone.'),
     t('Delete'), t('Cancel')
@@ -652,31 +642,26 @@ function mollom_admin_blacklist_delete($form, &$form_state) {
  * Form submit handler to delete an entry from the blacklist.
  */
 function mollom_admin_blacklist_delete_submit($form, &$form_state) {
-  $data = array(
-    'text' => $form_state['values']['text'],
-    'context' => $form_state['values']['context'],
-    'reason' => $form_state['values']['reason'],
-  );
-  $result = mollom('mollom.removeBlacklistText', $data);
+  $result = mollom()->deleteBlacklistEntry($form_state['values']['entry']['id']);
 
   $args = array(
-    '@text' => $data['text'],
-    '@context' => $data['context'],
-    '@reason' => $data['reason'],
+    '@value' => $form_state['values']['entry']['value'],
+    '@context' => $form_state['values']['entry']['context'],
+    '@reason' => $form_state['values']['entry']['reason'],
   );
   if ($result === TRUE) {
     drupal_set_message(t('The entry was removed from the blacklist.'));
     _mollom_watchdog(array(
-      'Removed @text (@context) from @reason blacklist.' => $args,
-      'Data:<pre>@data</pre>' => array('@data' => $data),
+      'Removed @value (@context) from @reason blacklist.' => $args,
+      'Data:<pre>@data</pre>' => array('@data' => $form_state['values']['entry']),
       'Result:<pre>@result</pre>' => array('@result' => $result),
     ));
   }
   else {
     drupal_set_message(t('An error occurred upon trying to remove the item from the blacklist.'), 'error');
     _mollom_watchdog(array(
-      'Failed to removed @text (%context) from @reason blacklist.' => $args,
-      'Data:<pre>@data</pre>' => array('@data' => $data),
+      'Failed to removed @value (%context) from @reason blacklist.' => $args,
+      'Data:<pre>@data</pre>' => array('@data' => $form_state['values']['entry']),
       'Result:<pre>@result</pre>' => array('@result' => $result),
     ), WATCHDOG_ERROR);
   }
@@ -692,6 +677,9 @@ function mollom_admin_blacklist_delete_submit($form, &$form_state) {
  * mollom.verifyKey would invalidate the keys and throw an error; hence,
  * _mollom_fallback() would invoke form_set_error(), effectively preventing this
  * form from submitting.
+ *
+ * @todo Implement proper form validation now that mollom() no longer triggers
+ *   the fallback mode.
  */
 function mollom_admin_settings($form, &$form_state) {
   // Output a positive status message, since users keep on asking whether
diff --git mollom.admin.js mollom.admin.js
index dff1bf9..051015a 100644
--- mollom.admin.js
+++ mollom.admin.js
@@ -8,12 +8,12 @@ Drupal.behaviors.mollomBlacklistFilter = {
     var self = this;
     $('#mollom-blacklist', context).once('mollom-blacklist-filter', function () {
       // Prepare a list of all entries to optimize performance. Each key is a
-      // blacklisted text and each value is an object containing the
+      // blacklisted value and each value is an object containing the
       // corresponding table row, context, and match.
       self.entries = {};
-      $(this).find('tr:has(.mollom-blacklist-text)').each(function () {
+      $(this).find('tr:has(.mollom-blacklist-value)').each(function () {
         var $row = $(this);
-        self.entries[$row.find('.mollom-blacklist-text').text()] = {
+        self.entries[$row.find('.mollom-blacklist-value').text()] = {
           context: $row.children('.mollom-blacklist-context').attr('class').match(/value-(\w+)/)[1],
           match: $row.children('.mollom-blacklist-match').attr('class').match(/value-(\w+)/)[1],
           row: $row.get(0)
@@ -21,7 +21,7 @@ Drupal.behaviors.mollomBlacklistFilter = {
       });
 
       // Attach the instant text filtering behavior.
-      var $filterText = $('#mollom-blacklist-filter-text', context);
+      var $filterText = $('#mollom-blacklist-filter-value', context);
       var $filterContext = $('#mollom-blacklist-filter-context', context);
       var $filterMatch = $('#mollom-blacklist-filter-match', context);
 
@@ -32,7 +32,7 @@ Drupal.behaviors.mollomBlacklistFilter = {
         var search = {
           // Blacklist entries are stored in lowercase, so to get any filter
           // results, the entered text must be converted to lowercase, too.
-          text: $filterText.val().toLowerCase(),
+          value: $filterText.val().toLowerCase(),
           context: $filterContext.val(),
           match: $filterMatch.val()
         };
@@ -53,15 +53,15 @@ Drupal.behaviors.mollomBlacklistFilter = {
         // Likewise, we directly apply the 'display' style, since
         // jQuery.fn.hide() and jQuery.fn.show() call into jQuery.fn.animate(),
         // which is useless for this purpose.
-        for (text in self.entries) {
-          visible = (search.text.length == 0 || text.indexOf(search.text) != -1);
-          visible = visible && (search.context.length == 0 || self.entries[text].context == search.context);
-          visible = visible && (search.match.length == 0 || self.entries[text].match == search.match);
+        for (value in self.entries) {
+          visible = (search.value.length == 0 || value.indexOf(search.value) != -1);
+          visible = visible && (search.context.length == 0 || self.entries[value].context == search.context);
+          visible = visible && (search.match.length == 0 || self.entries[value].match == search.match);
           if (visible) {
-            self.entries[text].row.style.display = '';
+            self.entries[value].row.style.display = '';
           }
           else {
-            self.entries[text].row.style.display = 'none';
+            self.entries[value].row.style.display = 'none';
           }
         }
       };
diff --git mollom.drupal.inc mollom.drupal.inc
new file mode 100644
index 0000000..ee8336c
--- /dev/null
+++ mollom.drupal.inc
@@ -0,0 +1,326 @@
+<?php
+
+/**
+ * @file
+ * Mollom client class for Drupal.
+ */
+
+/**
+ * Drupal Mollom client implementation.
+ */
+class MollomDrupal extends Mollom {
+  /**
+   * Mapping of configuration names to Drupal variables.
+   *
+   * @see Mollom::loadConfiguration()
+   */
+  private $configuration_map = array(
+    'publicKey' => 'mollom_public_key',
+    'privateKey' => 'mollom_private_key',
+    'servers' => 'mollom_servers',
+  );
+
+  /**
+   * Implements Mollom::loadConfiguration().
+   */
+  public function loadConfiguration($name) {
+    $name = $this->configuration_map[$name];
+    return variable_get($name);
+  }
+
+  /**
+   * Implements Mollom::saveConfiguration().
+   */
+  public function saveConfiguration($name, $value) {
+    $name = $this->configuration_map[$name];
+    return variable_set($name, $value);
+  }
+
+  /**
+   * Implements Mollom::deleteConfiguration().
+   */
+  public function deleteConfiguration($name) {
+    $name = $this->configuration_map[$name];
+    return variable_del($name);
+  }
+
+  /**
+   * Implements Mollom::getClientInformation().
+   */
+  public function getClientInformation() {
+    if ($cache = cache_get('mollom_version')) {
+      return $cache->data;
+    }
+
+    // Retrieve Drupal distribution and installation profile information.
+    $profile = drupal_get_profile();
+    $profile_info = system_get_info('module', $profile) + array(
+      'distribution_name' => 'Drupal',
+      'version' => VERSION,
+    );
+
+    // Retrieve Mollom module information.
+    $mollom_info = system_get_info('module', 'mollom');
+    if (empty($mollom_info['version'])) {
+      // Manually build a module version string for repository checkouts.
+      $mollom_info['version'] = DRUPAL_CORE_COMPATIBILITY . '-1.x-dev';
+    }
+
+    $data = array(
+      'platformName' => $profile_info['distribution_name'],
+      'platformVersion' => $profile_info['version'],
+      'clientName' => $mollom_info['name'],
+      'clientVersion' => $mollom_info['version'],
+    );
+    cache_set('mollom_version', $data);
+
+    return $data;
+  }
+
+  /**
+   * Overrides Mollom::writeLog().
+   */
+  function writeLog() {
+    $messages = array();
+    foreach ($this->log as $i => $entry) {
+      $entry += array('arguments' => array());
+      $message = array(
+        $entry['message'] => $entry['arguments'],
+      );
+      if (isset($entry['data'])) {
+        $message['Request: @request<pre>@parameters</pre>'] = array(
+          '@request' => $entry['request'],
+          '@parameters' => $entry['data'],
+        );
+        unset($entry['request'], $entry['data']);
+      }
+      if (isset($entry['response'])) {
+        $message['Response:<pre>@response</pre>'] = array('@response' => $entry['response']);
+      }
+      $messages[] = $message;
+
+      // Translate log messages for debugging without watchdog.
+      // @todo Move into mollom.unit.inc implementation.
+      /*
+      $output = array();
+      foreach ($message as $text => $args) {
+        foreach ($args as &$arg) {
+          if (is_array($arg)) {
+            $arg = var_export($arg, TRUE);
+          }
+        }
+        $output[] = strtr($text, $args);
+      }
+      $this->log[$i]['message'] = implode("\n", $output);
+      unset($this->log[$i]['arguments']);
+      drupal_set_message(implode('<br />', $output), $this->log[$i]['type']);
+      */
+    }
+    _mollom_watchdog_multiple($messages, $this->lastResponseCode === TRUE ? WATCHDOG_DEBUG : WATCHDOG_ERROR);
+
+    // After writing log messages, empty the log.
+    $this->purgeLog();
+  }
+
+  /**
+   * Overrides Mollom::refreshServers().
+   */
+  protected function refreshServers() {
+    $servers = parent::refreshServers();
+
+    // Allow other modules to alter the server list. Internal use only.
+    drupal_alter('mollom_server_list', $servers);
+
+    return $servers;
+  }
+
+  /**
+   * Implements Mollom::request().
+   */
+  protected function request($method, $server, $path, array $data, array $expected = array()) {
+    // Build the REST request path.
+    $rest_path = $path;
+    $query = NULL;
+    if ($data) {
+      $query = $this->httpBuildQuery($data);
+      if (in_array($method, array('GET', 'HEAD'))) {
+        $rest_path .= '?' . $query;
+      }
+    }
+
+    $request_data = array(
+      'method' => $method,
+      'headers' => array(
+        'Accept' => 'application/xml, application/json;q=0.8, */*;q=0.5',
+      ),
+    );
+    if (in_array($method, array('POST', 'PUT'))) {
+      $request_data['data'] = $query;
+      $request_data['headers']['Content-Type'] = 'application/x-www-form-urlencoded';
+    }
+
+    $dhr = drupal_http_request($server . '/' . $rest_path, $request_data);
+    // @todo Core: Ensure that $dhr->code is an integer.
+    $dhr->code = (int) $dhr->code;
+    // @todo Core: Any other code than 200 as interpreted as error.
+    $error = (isset($dhr->error) && ($dhr->code < 200 || $dhr->code >= 300));
+    // @todo Core: data property is not assigned if there is no response body.
+    if (!isset($dhr->data)) {
+      $dhr->data = NULL;
+    }
+
+    // Parse the response body into a PHP array.
+    if (isset($dhr->headers['content-type'])) {
+      if (strstr($dhr->headers['content-type'], 'application/json')) {
+        $dhr->data = drupal_json_decode($dhr->data);
+      }
+      elseif (strstr($dhr->headers['content-type'], 'application/xml')) {
+        $dhr->elements = new SimpleXmlIterator($dhr->data);
+        $dhr->data = $this->parseXML($dhr->elements);
+      }
+    }
+
+    // @todo The above is all that may vary between client implementations. All
+    //   of the below logic should live in a separate method that clients don't
+    //   have to re-implement:
+    //     handleResponse($http_code, $http_message, $response)
+    //   Or alternatively, since handleResponse() is required for every request,
+    //   move the above into a new sub-method invoked from request():
+    //     executeRequest($method, $server, $path, array $data)
+    //   and make it return $http_code, $http_message, array $response.
+
+    // If a 'code' exists in the response, it has precedence, regardless of a
+    // possible HTTP error code.
+    if (isset($dhr->data['code']) && is_array($dhr->data) && $dhr->data['code'] != 200) {
+      $error = TRUE;
+      // Replace HTTP status code with 'code' from response.
+      $dhr->code = (int) $dhr->data['code'];
+      // If there is no HTTP status message, take over 'message' from response.
+      if (!isset($dhr->error) && isset($dhr->data['message'])) {
+        $dhr->error = $dhr->data['message'];
+      }
+    }
+
+    // Verify that an expected element exists in the response.
+    // Normally, a response validation like this would likely be done using XML
+    // schemas, so the XML parser would already (in)validate the response.
+    // However, when using the JSON protocol, there is no comparable notion of a
+    // schema. Instead of checking for only one particular element in the
+    // response, the client could also introduce dedicated classes for each
+    // resource, but that feels like over-engineering for the average use-case.
+    $key_exists = TRUE;
+    if (!$error && !empty($expected)) {
+      $key_exists = FALSE;
+      if (is_array($dhr->data)) {
+        $key_exists = $this->arrayKeyExists($dhr->data, $expected);
+      }
+      if (!$key_exists) {
+        $error = TRUE;
+        $dhr->code = self::RESPONSE_ERROR;
+        $dhr->error = 'Unexpected server response.';
+      }
+    }
+
+    if ($error) {
+      $arguments = array(
+        'request' => $method . ' ' . $server . '/' . $path,
+        'data' => $data,
+        'response' => $dhr->data,
+      );
+      if ($dhr->code <= 0) {
+        throw new MollomNetworkException(t('Network error.'), self::NETWORK_ERROR, NULL, $this, $arguments);
+      }
+      if ($dhr->code == self::AUTH_ERROR || $dhr->code == 401) {
+        throw new MollomAuthenticationException(t('Invalid authentication.'), self::AUTH_ERROR, NULL, $this, $arguments);
+      }
+      if ($dhr->code == self::REDIRECT_ERROR || ($dhr->code >= 300 && $dhr->code < 400)) {
+        throw new MollomRedirectException(t('Server redirected to next.'), self::REDIRECT_ERROR, NULL, $this, $arguments);
+      }
+      if ($dhr->code == self::REFRESH_ERROR) {
+        throw new MollomRefreshException(t('Refresh of server list required.'), self::REFRESH_ERROR, NULL, $this, $arguments);
+      }
+      if (!$key_exists || $dhr->code >= 500) {
+        throw new MollomResponseException(t('Unexpected server response.'), self::RESPONSE_ERROR, NULL, $this, $arguments);
+      }
+      throw new MollomException($dhr->error, $dhr->code, NULL, $this, $arguments);
+    }
+    else {
+      $this->lastResponseCode = TRUE;
+      $this->log[] = array(
+        'type' => 'debug',
+        'message' => '@code @method @uri',
+        'arguments' => array(
+          '@code' => $dhr->code,
+          '@method' => $method,
+          '@uri' => $path,
+        ),
+        'request' => $method . ' ' . $server . '/' . $path,
+        'data' => $data,
+        'response' => $dhr->data,
+      );
+
+      return $dhr->data;
+    }
+  }
+}
+
+/**
+ * Drupal Mollom client implementation using production testing servers.
+ */
+class MollomDrupalTest extends MollomDrupal {
+  /**
+   * Overrides Mollom::$serversInit.
+   */
+  public $serversInit = array('http://rest.mollom.com');
+
+  /**
+   * Overrides Mollom::refreshServers().
+   *
+   * @todo FIXME: Site data is not consistent across servers. Testing site
+   *   record is initially created on first hard-coded server.
+   *   MollomServerListRecoveryTestCase replaces server list with invalid URLs
+   *   to verify proper recovery. Since there is no server list,
+   *   refreshServers() is invoked, which calls GET /site/$publicKey, which
+   *   contains a new server list. The first server in that list is used for
+   *   subsequent/following requests. But the first server in the returned list
+   *   can be different to the first server in our hard-coded list
+   *   (Mollom::serversInit), and in that case, authentication fails, and in
+   *   turn, all tests fail.
+   */
+  protected function refreshServers() {
+    // Retrieve new server list.
+    $servers = parent::refreshServers();
+    // Nothing to do on empty list.
+    if (empty($servers)) {
+      return $servers;
+    }
+
+    // Append API version to serversInit.
+    $known = array();
+    foreach ($this->serversInit as $server) {
+      $known[] = $server . '/' . self::API_VERSION;
+    }
+    // Check whether any of the hard-coded servers is contained, and if so, sort
+    // them first (and in their hard-coded order).
+    $new = array_intersect($known, $servers);
+    // Append the remaining.
+    $new = array_merge($new, array_diff($servers, $new));
+
+    return $new;
+  }
+}
+
+/**
+ * Drupal Mollom client implementation using local dummy/fake REST server.
+ */
+class MollomDrupalTestLocal extends MollomDrupal {
+  /**
+   * Overrides Mollom::__construct().
+   */
+  function __construct() {
+    parent::__construct();
+    // Replace initial server list with local fake server.
+    $this->serversInit = array($GLOBALS['base_url'] . '/mollom-test/rest');
+  }
+}
+
diff --git mollom.inc mollom.inc
deleted file mode 100644
index e79e863..0000000
--- mollom.inc
+++ /dev/null
@@ -1,114 +0,0 @@
-<?php
-
-/**
- * @file
- * Mollom client/server interaction functions.
- */
-
-/**
- * Generate authentication data for XML-RPC communication with Mollom servers.
- *
- * This function generates an array with all information required to
- * authenticate against Mollom.  To prevent forged requests where you are
- * impersonated, each request is signed with a hash based on a private
- * key and a timestamp.
- *
- * Both the client and the server share the secret key used to create
- * the authentication hash.  They both hash a timestamp with the secret
- * key, and if the hashes match, the authenticity of the message is
- * validated.
- *
- * To avoid someone intercepting a (hash, timestamp)-pair and using it
- * to impersonate a client, Mollom reject any request where the timestamp
- * is more than 15 minutes off.
- *
- * Make sure your server's time is synchronized with the world clocks,
- * and that you don't share your private key with anyone else.
- *
- * @param $public_key
- *   (optional) The public key to use for authentication. Only used internally.
- * @param $private_key
- *   (optional) The private key to use for authentication. Only used internally.
- */
-function _mollom_authentication($public_key = NULL, $private_key = NULL) {
-  if (!isset($public_key)) {
-    $public_key = variable_get('mollom_public_key', '');
-  }
-  if (!isset($private_key)) {
-    $private_key = variable_get('mollom_private_key', '');
-  }
-
-  // Generate a timestamp according to the dateTime format (http://www.w3.org/TR/xmlschema-2/#dateTime):
-  $time = gmdate("Y-m-d\TH:i:s.\\0\\0\\0O", REQUEST_TIME);
-
-  // Generate a random number:
-  $nonce = md5(mt_rand());
-
-  // Calculate a HMAC-SHA1 according to RFC 2104.
-  // @see http://www.ietf.org/rfc/rfc2104.txt
-  $request_data = $time . ':' . $nonce . ':' . $private_key;
-  $hash = base64_encode(hash_hmac('sha1', $request_data, $private_key, TRUE));
-
-  // Store everything in an array.  Elsewhere in the code, we'll add the
-  // actual data before we pass it onto the XML-RPC library:
-  $data['public_key'] = $public_key;
-  $data['time'] = $time;
-  $data['hash'] = $hash;
-  $data['nonce'] = $nonce;
-
-  return $data;
-}
-
-/**
- * Refreshes the list of Mollom's XML-RPC servers.
- */
-function _mollom_retrieve_server_list() {
-  // Start from a hard-coded list of servers.
-  $servers = array('http://xmlrpc1.mollom.com', 'http://xmlrpc2.mollom.com', 'http://xmlrpc3.mollom.com');
-  $messages = array();
-
-  // mollom.getServerList cannot use mollom() as we need to prevent infinite
-  // recursion. In addition, we handle returned error codes differently here,
-  // since MOLLOM_REDIRECT and MOLLOM_REFRESH, as well as any other
-  // communication error requires us to skip to the next server to retrieve a
-  // new server list. We only ever abort, if a server returns MOLLOM_ERROR, in
-  // which case there must be a configuration error (e.g., invalid API keys).
-  $method = 'mollom.getServerList';
-  foreach ($servers as $server) {
-    $result = xmlrpc($server . '/' . MOLLOM_API_VERSION, array($method => array(_mollom_authentication())));
-    if ($result === FALSE && ($error = xmlrpc_error())) {
-      // In any case, log the error.
-      $messages[] = array(
-        'Error @errno from %server for %method: %message' => array(
-          '@errno' => $error->code,
-          '%server' => $server,
-          '%method' => $method,
-          '%message' => $error->message,
-        ),
-      );
-      // Skip to the next server in case of any error, except if we have a
-      // MOLLOM_ERROR, which indicates a bogus configuration. In this case, stop
-      // trying, since all servers will fail.
-      if ($error->code === MOLLOM_ERROR) {
-        break;
-      }
-    }
-    // Otherwise, we have a valid result.
-    else {
-      break;
-    }
-  }
-
-  // Allow other modules to alter the server list. Internal use only.
-  drupal_alter('mollom_server_list', $result);
-
-  if (is_array($result)) {
-    _mollom_watchdog_multiple($messages, WATCHDOG_DEBUG);
-    return $result;
-  }
-  else {
-    _mollom_watchdog_multiple($messages, WATCHDOG_ERROR);
-    return xmlrpc_errno();
-  }
-}
-
diff --git mollom.info mollom.info
index 4027872..e4f259a 100644
--- mollom.info
+++ mollom.info
@@ -4,8 +4,7 @@ core = 7.x
 configure = admin/config/content/mollom
 scripts[] = mollom.js
 stylesheets[all][] = mollom.css
-files[] = mollom.module
-files[] = mollom.admin.inc
-files[] = mollom.pages.inc
-files[] = mollom.install
+files[] = includes/mollom.class.inc
+files[] = mollom.drupal.inc
 files[] = tests/mollom.test
+files[] = tests/mollom.class.test
diff --git mollom.install mollom.install
index 86200cc..1e40f75 100644
--- mollom.install
+++ mollom.install
@@ -52,14 +52,14 @@ function mollom_requirements($phase = 'runtime', $check = TRUE) {
       ));
     }
     // Invalid API keys.
-    elseif ($status['keys valid'] === MOLLOM_ERROR) {
+    elseif ($status['keys valid'] === Mollom::AUTH_ERROR) {
       $requirements['mollom']['value'] = t('Invalid');
       $requirements['mollom']['description'] = t('The configured Mollom API keys are invalid. !admin-message', array(
         '!admin-message' => $admin_message,
       ));
     }
     // Communication error.
-    elseif ($status['keys valid'] === NETWORK_ERROR) {
+    elseif ($status['keys valid'] === Mollom::NETWORK_ERROR) {
       $requirements['mollom']['value'] = t('Network error');
       $requirements['mollom']['description'] = t('The Mollom servers could not be contacted. Please make sure that your web server can make outgoing HTTP requests.');
     }
@@ -88,10 +88,17 @@ function mollom_schema() {
         'not null' => TRUE,
         'default' => '',
       ),
-      'session_id' => array(
-        'description' => 'Session hash returned by Mollom.',
+      'contentId' => array(
+        'description' => 'Content ID returned by Mollom.',
         'type' => 'varchar',
-        'length' => 255,
+        'length' => 128,
+        'not null' => TRUE,
+        'default' => '',
+      ),
+      'captchaId' => array(
+        'description' => 'CAPTCHA ID returned by Mollom.',
+        'type' => 'varchar',
+        'length' => 128,
         'not null' => TRUE,
         'default' => '',
       ),
@@ -119,19 +126,27 @@ function mollom_schema() {
       // would have an unintended meaning. Also, values are stored in individual
       // columns, so as to be able to join and filter/sort on these values for
       // improved content moderation.
-      'spam' => array(
+      'spamScore' => array(
         'description' => 'Text analysis spam check result.',
-        'type' => 'int',
+        'type' => 'float',
         'size' => 'tiny',
         'not null' => FALSE,
       ),
-      'quality' => array(
+      'spamClassification' => array(
+        'description' => 'Text analysis final spam classification result.',
+        'type' => 'varchar',
+        'length' => 16,
+        'not null' => FALSE,
+      ),
+      // @todo Add Content API result 'reason'.
+      // @todo Add CAPTCHA API results 'solved', 'reason'.
+      'qualityScore' => array(
         'description' => 'Text analysis quality check result.',
         'type' => 'float',
         'size' => 'tiny',
         'not null' => FALSE,
       ),
-      'profanity' => array(
+      'profanityScore' => array(
         'description' => 'Text analysis profanity check result.',
         'type' => 'float',
         'size' => 'tiny',
@@ -145,7 +160,10 @@ function mollom_schema() {
         'default' => '',
       ),
     ),
-    'indexes' => array('session_id' => array('session_id')),
+    'indexes' => array(
+      'contentId' => array('contentId'),
+      'captchaId' => array('captchaId'),
+    ),
     'primary key' => array('entity', 'id'),
     'foreign keys' => array(
       'mollom_form_id' => array(
@@ -584,17 +602,17 @@ function mollom_update_7005() {
     // Fill {mollom}.spam with approximate values based on {mollom}.quality.
     // Note that this is just to have some values. 'quality' and 'spam' are
     // completely unrelated otherwise.
-    // MOLLOM_ANALYSIS_SPAM
+    // MOLLOM_ANALYSIS_SPAM (2)
     db_update('mollom')
       ->fields(array('spam' => 2))
       ->condition('quality', 0.5, '<')
       ->execute();
-    // MOLLOM_ANALYSIS_UNSURE
+    // MOLLOM_ANALYSIS_UNSURE (3)
     db_update('mollom')
       ->fields(array('spam' => 3))
       ->condition('quality', 0.5, '=')
       ->execute();
-    // MOLLOM_ANALYSIS_HAM
+    // MOLLOM_ANALYSIS_HAM (1)
     db_update('mollom')
       ->fields(array('spam' => 1))
       ->condition('quality', 0.5, '>')
@@ -834,3 +852,65 @@ function mollom_update_7011() {
     ->fields(array('strictness' => 'normal'))
     ->execute();
 }
+
+/**
+ * Change {mollom}: Add 'spamResult', change 'spam', replace 'session_id' with 'contentId' and 'captchaId'.
+ */
+function mollom_update_7012() {
+  if (db_field_exists('mollom', 'session_id')) {
+    // Truncate table. Old session IDs cannot be migrated.
+    db_truncate('mollom')->execute();
+    // Remove 'session_id'.
+    db_drop_index('mollom', 'session_id');
+    db_drop_field('mollom', 'session_id');
+
+    // Add 'contentId' and 'captchaId'.
+    db_add_field('mollom', 'contentId', array(
+      'description' => 'Content ID returned by Mollom.',
+      'type' => 'varchar',
+      'length' => 128,
+      'not null' => TRUE,
+      'default' => '',
+    ));
+    db_add_field('mollom', 'captchaId', array(
+      'description' => 'CAPTCHA ID returned by Mollom.',
+      'type' => 'varchar',
+      'length' => 128,
+      'not null' => TRUE,
+      'default' => '',
+    ));
+    db_add_index('mollom', 'contentId', array('contentId'));
+    db_add_index('mollom', 'captchaId', array('captchaId'));
+  }
+  if (!db_field_exists('mollom', 'spamClassification')) {
+    // Add 'spamClassification'.
+    db_add_field('mollom', 'spamClassification', array(
+      'description' => 'Text analysis final spam classification result.',
+      'type' => 'varchar',
+      'length' => 16,
+      'not null' => FALSE,
+    ));
+    // Change 'spam' into 'spamScore' double.
+    db_change_field('mollom', 'spam', 'spamScore', array(
+      'description' => 'Text analysis spam check result.',
+      'type' => 'float',
+      'size' => 'tiny',
+      'not null' => FALSE,
+    ));
+    // Change 'profanity' into 'profanityScore' double.
+    db_change_field('mollom', 'profanity', 'profanityScore', array(
+      'description' => 'Text analysis profanity check result.',
+      'type' => 'float',
+      'size' => 'tiny',
+      'not null' => FALSE,
+    ));
+    // Change 'quality' into 'qualityScore' double.
+    db_change_field('mollom', 'quality', 'qualityScore', array(
+      'description' => 'Text analysis quality check result.',
+      'type' => 'float',
+      'size' => 'tiny',
+      'not null' => FALSE,
+    ));
+  }
+}
+
diff --git mollom.js mollom.js
index d20be15..026191d 100644
--- mollom.js
+++ mollom.js
@@ -34,12 +34,12 @@ function getMollomCaptcha() {
 
   var context = $(this).parents('form');
 
-  // Extract the Mollom session id and form build id from the form.
-  var mollomSessionId = $('input.mollom-session-id', context).val();
+  // Extract the Mollom CAPTCHA ID and form build ID from the form.
+  var mollomCaptchaId = $('input.mollom-captcha-id', context).val();
   var formBuildId = $('input[name="form_build_id"]', context).val();
 
   // Retrieve a CAPTCHA:
-  $.getJSON(Drupal.settings.basePath + 'mollom/captcha/' + newCaptchaType + '/' + formBuildId + '/' + mollomSessionId,
+  $.getJSON(Drupal.settings.basePath + 'mollom/captcha/' + newCaptchaType + '/' + formBuildId + '/' + mollomCaptchaId,
     function (data) {
       if (!(data && data.content)) {
         return;
@@ -47,7 +47,7 @@ function getMollomCaptcha() {
       // Inject new CAPTCHA.
       $('.mollom-captcha-content', context).parent().html(data.content);
       // Update session id.
-      $('input.mollom-session-id', context).val(data.session_id);
+      $('input.mollom-captcha-id', context).val(data.captchaId);
       // Add an onclick-event handler for the new link.
       Drupal.attachBehaviors(context);
       // Focus on the CATPCHA input.
diff --git mollom.module mollom.module
index 98f09c5..20ed4ad 100644
--- mollom.module
+++ mollom.module
@@ -6,31 +6,6 @@
  */
 
 /**
- * Mollom API version; used for XML-RPC communication with Mollom servers.
- */
-define('MOLLOM_API_VERSION', '1.0');
-
-/**
- * Text analysis result flag: No result.
- */
-define('MOLLOM_ANALYSIS_UNKNOWN', 0);
-
-/**
- * Text analysis result flag: Content is no spam.
- */
-define('MOLLOM_ANALYSIS_HAM', 1);
-
-/**
- * Text analysis result flag: Content is spam.
- */
-define('MOLLOM_ANALYSIS_SPAM', 2);
-
-/**
- * Text analysis result flag: Ambiguous result.
- */
-define('MOLLOM_ANALYSIS_UNSURE', 3);
-
-/**
  * Form protection mode: No protection.
  */
 define('MOLLOM_MODE_DISABLED', 0);
@@ -46,44 +21,16 @@ define('MOLLOM_MODE_CAPTCHA', 1);
 define('MOLLOM_MODE_ANALYSIS', 2);
 
 /**
- * XML-RPC communication failure fallback mode: Block all submissions of protected forms.
+ * Server communication failure fallback mode: Block all submissions of protected forms.
  */
 define('MOLLOM_FALLBACK_BLOCK', 0);
 
 /**
- * XML-RPC communication failure fallback mode: Accept all submissions of protected forms.
+ * Server communication failure fallback mode: Accept all submissions of protected forms.
  */
 define('MOLLOM_FALLBACK_ACCEPT', 1);
 
 /**
- * XML-RPC communication failure: No servers could be reached.
- *
- * @todo Prefix with MOLLOM_.
- */
-define('NETWORK_ERROR', 900);
-
-/**
- * XML-RPC communication failure: Error on Mollom server.
- *
- * @todo Prefix with SERVER_.
- */
-define('MOLLOM_ERROR', 1000);
-
-/**
- * XML-RPC communication failure: Mollom server requests client to update its server list.
- *
- * @todo Prefix with SERVER_.
- */
-define('MOLLOM_REFRESH', 1100);
-
-/**
- * XML-RPC communication failure: Mollom server defers communication to next server in server list.
- *
- * @todo Prefix with SERVER_.
- */
-define('MOLLOM_REDIRECT', 1200);
-
-/**
  * Implements hook_hook_info().
  */
 function mollom_hook_info() {
@@ -278,10 +225,10 @@ function mollom_menu() {
     'type' => MENU_LOCAL_TASK,
     'file' => 'mollom.admin.inc',
   );
-  $items['admin/config/content/mollom/blacklist/delete'] = array(
+  $items['admin/config/content/mollom/blacklist/delete/%'] = array(
     'title' => 'Delete',
     'page callback' => 'drupal_get_form',
-    'page arguments' => array('mollom_admin_blacklist_delete'),
+    'page arguments' => array('mollom_admin_blacklist_delete', 6),
     'access arguments' => array('administer mollom'),
     'type' => MENU_CALLBACK,
     'file' => 'mollom.admin.inc',
@@ -338,10 +285,15 @@ function _mollom_access($permission = FALSE) {
 /**
  * Menu access callback; Determine access to report to Mollom.
  *
- * The special $entity type "session" may be used for mails and messages, which
- * originate from form submissions protected by Mollom, and can be reported by
- * anyone; $id is expected to be a Mollom session id instead of an entity id
- * then.
+ * There are two special $entity types "mollom_content" and "mollom_captcha",
+ * which do not map to actual entity types in the Drupal system. They are
+ * primarily used for mails, messages, and posts, which pertain to forms
+ * protected by Mollom that do no result in stored entities after submission.
+ * For example, Contact module's contact form. They can be reported by anyone
+ * having the link. $id is expected to be either a {mollom}.contentId or
+ * {mollom}.captchaId respectively.
+ *
+ * @see mollom_mail_add_report_link()
  *
  * @param $entity
  *   The entity type of the data to report.
@@ -351,9 +303,8 @@ function _mollom_access($permission = FALSE) {
  * @todo Revamp this based on new {mollom}.form_id info.
  */
 function mollom_report_access($entity, $id) {
-  // The special entity 'session' means that $id is a Mollom session_id, which
-  // can always be reported by everyone.
-  if ($entity == 'session') {
+  // The special entity types can be reported by anyone.
+  if ($entity == 'mollom_content' || $entity == 'mollom_captcha') {
     return !empty($id) ? TRUE : FALSE;
   }
   // Retrieve information about all protectable forms. We use the first valid
@@ -434,7 +385,30 @@ function mollom_cron() {
  *   The entity id to retrieve data for.
  */
 function mollom_data_load($entity, $id) {
-  return db_query_range('SELECT * FROM {mollom} WHERE entity = :entity AND id = :id', 0, 1, array(':entity' => $entity, ':id' => $id))->fetchObject();
+  return mollom_db_query_range('SELECT * FROM {mollom} WHERE entity = :entity AND id = :id', 0, 1, array(':entity' => $entity, ':id' => $id))->fetchObject();
+}
+
+/**
+ * Fetches a database record with natural letter casing.
+ *
+ * Drupal core enforces lowercase column names in PDO statements for no
+ * particular reason.
+ *
+ * @see http://drupal.org/node/1171866
+ */
+function mollom_db_query_range($query, $from, $count, array $args = array(), array $options = array()) {
+  if (empty($options['target'])) {
+    $options['target'] = 'default';
+  }
+
+  $connection = Database::getConnection($options['target']);
+  // Backup PDO::ATTR_CASE to restore it afterwards, sticks on the connection.
+  $backup = $connection->getAttribute(PDO::ATTR_CASE);
+  $connection->setAttribute(PDO::ATTR_CASE, PDO::CASE_NATURAL);
+  $result = $connection->queryRange($query, $from, $count, $args, $options);
+  $connection->setAttribute(PDO::ATTR_CASE, $backup);
+
+  return $result;
 }
 
 /**
@@ -456,8 +430,9 @@ function mollom_data_load($entity, $id) {
  *   - form_id: The form ID the session data belongs to.
  *   - session_id: The session ID returned by Mollom.
  *   And optionally:
- *   - spam: A spam check result integer returned by Mollom, which can be
- *     MOLLOM_ANALYSIS_SPAM, MOLLOM_ANALYSIS_UNSURE, or MOLLOM_ANALYSIS_HAM.
+ *   - spam: A spam check result double returned by Mollom.
+ *   - spamClassification: A final spam classification result string; 'ham',
+ *     'spam', or 'unsure'.
  *   - quality: A rating of the content's quality, in the range of 0 and 1.0.
  *   - profanity: A profanity check rating returned by Mollom, in the range of
  *     0 and 1.0.
@@ -466,10 +441,14 @@ function mollom_data_load($entity, $id) {
  */
 function mollom_data_save($data) {
   $data->changed = REQUEST_TIME;
+
   // Convert languages array into a string.
-  // @todo This conversion and data handling is not correct; needs work.
   if (isset($data->languages) && is_array($data->languages)) {
-    $data->languages = implode(' ', $data->languages);
+    $languages = array();
+    foreach ($data->languages as $language) {
+      $languages[] = $language['languageCode'];
+    }
+    $data->languages = implode(',', $languages);
   }
 
   $update = db_query_range("SELECT 'id' FROM {mollom} WHERE entity = :entity AND id = :id", 0, 1, array(
@@ -504,7 +483,7 @@ function mollom_data_moderate($entity, $id) {
   }
 
   // Report the session to Mollom.
-  _mollom_send_feedback($data->session_id, 'ham');
+  _mollom_send_feedback($data, 'approve');
 
   // Mark the session data as moderated.
   $data->moderate = 0;
@@ -576,7 +555,7 @@ function mollom_data_delete_form_submit($form, &$form_state) {
   $data = mollom_form_get_values($form_state, $mollom_form['enabled_fields'], $mollom_form['mapping']);
 
   $entity = $mollom_form['entity'];
-  $id = $data['post_id'];
+  $id = $data['postId'];
 
   if (!empty($form_state['values']['mollom']['feedback'])) {
     if (mollom_data_report($entity, $id, $form_state['values']['mollom']['feedback'])) {
@@ -614,8 +593,8 @@ function mollom_data_report_multiple($entity, array $ids, $feedback) {
     // Load the Mollom session data.
     $data = mollom_data_load($entity, $id);
     // Send feedback, if we have session data.
-    if (!empty($data->session_id)) {
-      $result = _mollom_send_feedback($data->session_id, $feedback);
+    if (!empty($data->contentId) || !empty($data->captchaId)) {
+      $result = _mollom_send_feedback($data, $feedback);
       $return = $return && $result;
     }
   }
@@ -1084,73 +1063,73 @@ function mollom_form_get_values($form_state, $fields, $mapping) {
   // Post id; not sent to Mollom.
   // @see mollom_form_submit()
   if (!empty($mapping['post_id'])) {
-    $data['post_id'] = $mapping['post_id'];
+    $data['postId'] = $mapping['post_id'];
   }
   // Post title.
   if (!empty($mapping['post_title'])) {
-    $data['post_title'] = $mapping['post_title'];
+    $data['postTitle'] = $mapping['post_title'];
   }
   // Post body.
   if (!empty($post_body)) {
-    $data['post_body'] = $post_body;
+    $data['postBody'] = $post_body;
   }
 
   // User name.
   if (!empty($mapping['author_name'])) {
-    $data['author_name'] = $mapping['author_name'];
+    $data['authorName'] = $mapping['author_name'];
     // Try to inherit user from author name.
-    $account = user_load_by_name($data['author_name']);
+    $account = user_load_by_name($data['authorName']);
   }
   elseif (!empty($user->name)) {
-    $data['author_name'] = $user->name;
+    $data['authorName'] = $user->name;
   }
 
   // User e-mail.
   if (!empty($mapping['author_mail'])) {
-    $data['author_mail'] = $mapping['author_mail'];
+    $data['authorMail'] = $mapping['author_mail'];
   }
   elseif (!empty($data['author_name'])) {
     if (!empty($account->mail)) {
-      $data['author_mail'] = $account->mail;
+      $data['authorMail'] = $account->mail;
     }
   }
   elseif (!empty($user->mail)) {
-    $data['author_mail'] = $user->mail;
+    $data['authorMail'] = $user->mail;
   }
 
   // User homepage.
   if (!empty($mapping['author_url'])) {
-    $data['author_url'] = $mapping['author_url'];
+    $data['authorUrl'] = $mapping['author_url'];
   }
 
   // User ID.
   if (!empty($mapping['author_id'])) {
-    $data['author_id'] = $mapping['author_id'];
+    $data['authorId'] = $mapping['author_id'];
   }
   elseif (!empty($data['author_name'])) {
     if (!empty($account->uid)) {
-      $data['author_id'] = $account->uid;
+      $data['authorId'] = $account->uid;
     }
   }
   elseif (!empty($user->uid)) {
-    $data['author_id'] = $user->uid;
+    $data['authorId'] = $user->uid;
   }
 
   // User OpenID.
   if (!empty($mapping['author_openid'])) {
-    $data['author_openid'] = $mapping['author_openid'];
+    $data['authorOpenid'] = $mapping['author_openid'];
   }
   elseif (!empty($data['author_id'])) {
     if (!empty($account->uid) && ($openid = _mollom_get_openid($account))) {
-      $data['author_openid'] = $openid;
+      $data['authorOpenid'] = $openid;
     }
   }
   elseif (!empty($user->uid) && ($openid = _mollom_get_openid($user))) {
-    $data['author_openid'] = $openid;
+    $data['authorOpenid'] = $openid;
   }
 
   // User IP.
-  $data['author_ip'] = ip_address();
+  $data['authorIp'] = ip_address();
 
   // Honeypot.
   // For the Mollom backend, it only matters whether 'honeypot' is non-empty.
@@ -1260,7 +1239,7 @@ function _mollom_status($reset = FALSE) {
 
   // If we have keys and are asked to reset, check whether keys are valid.
   if ($status['keys'] && $reset) {
-    $status['keys valid'] = mollom('mollom.verifyKey', _mollom_get_version());
+    $status['keys valid'] = mollom()->verifyKey();
   }
   // Otherwise, if there are no keys, they cannot be valid.
   elseif (!$status['keys']) {
@@ -1310,19 +1289,9 @@ function _mollom_testing_mode_warning() {
  */
 function _mollom_fallback() {
   $fallback = variable_get('mollom_fallback', MOLLOM_FALLBACK_BLOCK);
-  // @todo Prevents mollom_admin_settings() from implementing a proper form
-  //   validation. Add !empty($_POST) to this condition + manually invoke from
-  //   mollom_process_form() on GET requests? Or don't call it from mollom()?
-  //   Anything, but just don't mix FAPI logic into XML-RPC logic.
   if ($fallback == MOLLOM_FALLBACK_BLOCK) {
     form_set_error('mollom', t("The spam filter installed on this site is currently unavailable. Per site policy, we are unable to accept new submissions until that problem is resolved. Please try resubmitting the form in a couple of minutes."));
   }
-
-  $servers = variable_get('mollom_servers', array());
-  _mollom_watchdog(array(
-    'All servers unavailable: %servers' => array('%servers' => $servers ? implode(', ', $servers) : '--'),
-    'Last error: @code %message' => array('@code' => xmlrpc_errno(), '%message' => xmlrpc_error_msg()),
-  ), WATCHDOG_ERROR);
 }
 
 /**
@@ -1380,9 +1349,6 @@ function mollom_theme() {
  * The 'mollom' form element is stateful. The Mollom session ID that is exchanged
  * between Drupal, the Mollom back-end, and the user allows us to keep track of
  * the form validation state.
- *
- * The session ID is valid for a given $form_id only. We expire it as soon as
- * the form is submitted, to avoid it being replayed.
  */
 function mollom_process_mollom($element, &$form_state, $complete_form) {
   // Setup initial Mollom session and form information.
@@ -1391,11 +1357,12 @@ function mollom_process_mollom($element, &$form_state, $complete_form) {
     'require_analysis' => $element['#mollom_form']['mode'] == MOLLOM_MODE_ANALYSIS,
     'require_captcha' => $element['#mollom_form']['mode'] == MOLLOM_MODE_CAPTCHA,
     'passed_captcha' => FALSE,
+    'captcha_type' => 'image',
     'require_moderation' => FALSE,
     'response' => array(
-      'session_id' => '',
     ),
   );
+  // @todo Put the Mollom instance into $form_state.
   $form_state['mollom'] += $element['#mollom_form'];
 
   // By default, bad form submissions are discarded, unless the form was
@@ -1406,10 +1373,15 @@ function mollom_process_mollom($element, &$form_state, $complete_form) {
   }
 
   // Add the Mollom session element.
-  $element['session_id'] = array(
+  $element['contentId'] = array(
     '#type' => 'hidden',
-    '#default_value' => isset($form_state['mollom']['response']['session_id']) ? $form_state['mollom']['response']['session_id'] : '',
-    '#attributes' => array('class' => 'mollom-session-id'),
+    '#default_value' => isset($form_state['mollom']['response']['content']['id']) ? $form_state['mollom']['response']['content']['id'] : '',
+    '#attributes' => array('class' => 'mollom-content-id'),
+  );
+  $element['captchaId'] = array(
+    '#type' => 'hidden',
+    '#default_value' => isset($form_state['mollom']['response']['captcha']['id']) ? $form_state['mollom']['response']['captcha']['id'] : '',
+    '#attributes' => array('class' => 'mollom-captcha-id'),
   );
 
   // Add the CAPTCHA element.
@@ -1434,19 +1406,12 @@ function mollom_process_mollom($element, &$form_state, $complete_form) {
     // Prevent the page cache from storing a form containing a CAPTCHA element.
     $GLOBALS['conf']['cache'] = 0;
 
-    $data = array();
-    if (!empty($form_state['mollom']['response']['session_id'])) {
-      $data['session_id'] = $form_state['mollom']['response']['session_id'];
-    }
-    $captcha = mollom_get_captcha('image', $data);
-
+    $captcha = mollom_get_captcha($form_state);
     // If we get a response, add the image CAPTCHA to the form element.
-    if (isset($captcha['response']['session_id']) && !empty($captcha['markup'])) {
-      $element['captcha']['#field_prefix'] = $captcha['markup'];
-
+    if (!empty($captcha)) {
+      $element['captcha']['#field_prefix'] = $captcha;
       // Assign the session ID returned by Mollom.
-      $form_state['mollom']['response']['session_id'] = $captcha['response']['session_id'];
-      $element['session_id']['#value'] = $captcha['response']['session_id'];
+      $element['captchaId']['#value'] = $form_state['mollom']['response']['captcha']['id'];
     }
     // Otherwise, we have a communication or configuration error.
     // @todo Short-cut form processing entirely in this case; see also
@@ -1454,6 +1419,8 @@ function mollom_process_mollom($element, &$form_state, $complete_form) {
     else {
       $form_state['mollom']['require_analysis'] = FALSE;
       $form_state['mollom']['require_captcha'] = FALSE;
+      // Trigger fallback mode.
+      _mollom_fallback();
       return array();
     }
   }
@@ -1463,8 +1430,6 @@ function mollom_process_mollom($element, &$form_state, $complete_form) {
   }
 
   // Add a spambot trap. Purposively use 'homepage' as field name.
-  // @todo Use a random field name (from the usual names of 'name', 'email',
-  //   'url', etc.) to make it harder to identify this trap.
   $element['homepage'] = array(
     '#type' => 'textfield',
     // Wrap the entire honeypot form element markup into a hidden container, so
@@ -1514,38 +1479,40 @@ function mollom_validate_analysis(&$form, &$form_state) {
     return;
   }
   $data = $all_data;
-  // Remove 'post_id' property; only used by mollom_form_submit().
-  if (isset($data['post_id'])) {
-    unset($data['post_id']);
+  // Remove postId property; only used by mollom_form_submit().
+  if (isset($data['postId'])) {
+    unset($data['postId']);
   }
-  $data['session_id'] = $form_state['mollom']['response']['session_id'];
-  $data['checks'] = implode(',', $form_state['mollom']['checks']);
+  if (isset($form_state['mollom']['response']['content']['id'])) {
+    $data['id'] = $form_state['mollom']['response']['content']['id'];
+  }
+  $data['checks'] = $form_state['mollom']['checks'];
   $data['strictness'] = $form_state['mollom']['strictness'];
-  $result = mollom('mollom.checkContent', $data);
+  $result = mollom()->checkContent($data);
 
   // Use all available data properties for log messages below.
   $data += $all_data;
 
   // Trigger global fallback behavior if there is no result.
-  if (!isset($result['session_id'])) {
+  if (!is_array($result) || !isset($result['id'])) {
     return _mollom_fallback();
   }
 
   // Store the response returned by Mollom.
-  $form_state['mollom']['response'] = $result;
-  $form['mollom']['session_id']['#value'] = $result['session_id'];
+  $form_state['mollom']['response']['content'] = $result;
+  $form['mollom']['contentId']['#value'] = $result['id'];
 
   // Prepare watchdog message teaser text.
   $teaser = '--';
-  if (isset($data['post_title'])) {
-    $teaser = truncate_utf8(strip_tags($data['post_title']), 40);
+  if (isset($data['postTitle'])) {
+    $teaser = truncate_utf8(strip_tags($data['postTitle']), 40);
   }
-  elseif (isset($data['post_body'])) {
-    $teaser = truncate_utf8(strip_tags($data['post_body']), 40);
+  elseif (isset($data['postBody'])) {
+    $teaser = truncate_utf8(strip_tags($data['postBody']), 40);
   }
 
   // Handle the profanity check result.
-  if (isset($result['profanity']) && $result['profanity'] >= 0.5) {
+  if (isset($result['profanityScore']) && $result['profanityScore'] >= 0.5) {
     if ($form_state['mollom']['discard']) {
       form_set_error('mollom', t('Your submission has triggered the profanity filter and will not be accepted until the inappropriate language is removed.'));
     }
@@ -1559,17 +1526,17 @@ function mollom_validate_analysis(&$form, &$form_state) {
     ));
   }
 
-  // Handle the spam check result.
+  // Handle the final spam classification result.
   // The Mollom backend is remembering results of previous mollom.checkContent
   // invocations for a single user/post session. When content is re-checked
   // during form validation, the result may change according to the values that
   // have been submitted (which e.g. can change during previews). Only in case
-  // the spam check led to a MOLLOM_ANALYSIS_UNSURE result, and the user solved
-  // the CAPTCHA correctly, subsequent spam check results will likely be
-  // MOLLOM_ANALYSIS_HAM (though not guaranteed).
-  if (isset($result['spam'])) {
-    switch ($result['spam']) {
-      case MOLLOM_ANALYSIS_HAM:
+  // the spam check led to a 'unsure' result, and the user solved the CAPTCHA
+  // correctly, subsequent spam check results will likely be 'ham' (though not
+  // guaranteed).
+  if (isset($result['spamClassification'])) {
+    switch ($result['spamClassification']) {
+      case 'ham':
         $form_state['mollom']['require_captcha'] = FALSE;
         _mollom_watchdog(array(
           'Ham: %teaser' => array('%teaser' => $teaser),
@@ -1578,7 +1545,7 @@ function mollom_validate_analysis(&$form, &$form_state) {
         ), WATCHDOG_INFO);
         break;
 
-      case MOLLOM_ANALYSIS_SPAM:
+      case 'spam':
         $form_state['mollom']['require_captcha'] = FALSE;
         if ($form_state['mollom']['discard']) {
           form_set_error('mollom', t('Your submission has triggered the spam filter and will not be accepted.'));
@@ -1593,7 +1560,7 @@ function mollom_validate_analysis(&$form, &$form_state) {
         ));
         break;
 
-      case MOLLOM_ANALYSIS_UNSURE:
+      case 'unsure':
         _mollom_watchdog(array(
           'Unsure: %teaser' => array('%teaser' => $teaser),
           'Data:<pre>@data</pre>' => array('@data' => $data),
@@ -1611,16 +1578,11 @@ function mollom_validate_analysis(&$form, &$form_state) {
           $form['mollom']['captcha']['#access'] = TRUE;
           $form['mollom']['captcha']['#required'] = TRUE;
 
-          $captcha_data = array(
-            'session_id' => $result['session_id'],
-          );
-          $captcha = mollom_get_captcha('image', $captcha_data);
-
+          $captcha = mollom_get_captcha($form_state);
           // If we get a response, add the image CAPTCHA to the form element.
-          if (isset($captcha['response']['session_id']) && !empty($captcha['markup'])) {
-            $form_state['mollom']['response']['session_id'] = $captcha['response']['session_id'];
-            $form['mollom']['session_id']['#value'] = $captcha['response']['session_id'];
-            $form['mollom']['captcha']['#field_prefix'] = $captcha['markup'];
+          if (!empty($captcha)) {
+            $form['mollom']['captchaId']['#value'] = $form_state['mollom']['response']['captcha']['id'];
+            $form['mollom']['captcha']['#field_prefix'] = $captcha;
           }
         }
         break;
@@ -1678,32 +1640,32 @@ function mollom_validate_captcha(&$form, &$form_state) {
     return;
   }
   $data = array(
-    'session_id' => $form_state['mollom']['response']['session_id'],
-    'captcha_result' => $form_state['values']['mollom']['captcha'],
-    'author_ip' => $all_data['author_ip'],
+    'id' => $form_state['mollom']['response']['captcha']['id'],
+    'solution' => $form_state['values']['mollom']['captcha'],
+    'authorIp' => $all_data['authorIp'],
   );
-  if (isset($all_data['author_id'])) {
-    $data['author_id'] = $all_data['author_id'];
+  if (isset($all_data['authorId'])) {
+    $data['authorId'] = $all_data['authorId'];
   }
   if (isset($all_data['honeypot'])) {
     $data['honeypot'] = $all_data['honeypot'];
   }
-  $result = mollom('mollom.checkCaptcha', $data);
+  $result = mollom()->checkCaptcha($data);
   // Use all available data properties for log messages below.
   $data += $all_data;
 
   // Invoke fallback behavior upon a server error; communication errors are
   // handled by mollom() already. A server error may happen in case of an
   // expired or invalid session_id.
-  if ($result === MOLLOM_ERROR) {
+  if (!is_array($result) || !isset($result['id'])) {
     return _mollom_fallback();
   }
 
   // Store the response for #submit handlers.
   $form_state['mollom']['response']['captcha'] = $result;
-  $form['mollom']['session_id']['#value'] = $form_state['mollom']['response']['session_id'];
+  $form['mollom']['captchaId']['#value'] = $form_state['mollom']['response']['captcha']['id'];
 
-  if ($result === TRUE) {
+  if (!empty($result['solved'])) {
     $form_state['mollom']['passed_captcha'] = TRUE;
     $form['mollom']['captcha']['#access'] = FALSE;
 
@@ -1786,11 +1748,20 @@ function mollom_form_submit($form, &$form_state) {
     // the mapped post_id.
     $values = mollom_form_get_values($form_state, array(), $form_state['mollom']['mapping']);
     // We only consider non-empty and non-zero values as valid entity ids.
-    if (!empty($values['post_id'])) {
+    if (!empty($values['postId'])) {
       // Save the Mollom session data.
-      $data = (object) $form_state['mollom']['response'];
+      $response = array();
+      if (isset($form_state['mollom']['response']['content'])) {
+        $response += $form_state['mollom']['response']['content'];
+        $response['contentId'] = $form_state['mollom']['response']['content']['id'];
+      }
+      if (isset($form_state['mollom']['response']['captcha'])) {
+        $response += $form_state['mollom']['response']['captcha'];
+        $response['captchaId'] = $form_state['mollom']['response']['captcha']['id'];
+      }
+      $data = (object) $response;
       $data->entity = $form_state['mollom']['entity'];
-      $data->id = $values['post_id'];
+      $data->id = $values['postId'];
       $data->form_id = $form_state['mollom']['form_id'];
       // Set the moderation flag for forms accepting bad posts.
       $data->moderate = $form_state['mollom']['require_moderation'];
@@ -1804,141 +1775,29 @@ function mollom_form_submit($form, &$form_state) {
  */
 
 /**
- * Call a remote procedure at the Mollom server.
+ * Instantiates a new Mollom client.
  *
- * This function automatically adds the information required to authenticate
- * against Mollom.
- *
- * @todo Currently, this function's return value mixes actual values and
- *   error values. We should rewrite the error handling so that calling
- *   functions can properly handle error situations.
- */
-function mollom($method, $data = array()) {
-  module_load_include('inc', 'mollom');
-  $messages = array();
-
-  // Initialize refresh variable.
-  $refresh = FALSE;
-
-  // Enable testing mode.
-  if (variable_get('mollom_testing_mode', 0)) {
-    $data['testing'] = TRUE;
-  }
-
-  // Retrieve the list of Mollom servers from the database.
-  $servers = variable_get('mollom_servers', array());
-
-  if (empty($servers)) {
-    // Retrieve a new list of servers.
-    $servers = _mollom_retrieve_server_list();
-    // If API keys are invalid, a XML-RPC error code is returned.
-    if (!is_array($servers)) {
-      return $servers;
+ * @param $class
+ *   (optional) The name of a Mollom client implementation class to instantiate.
+ *   Overrides the 'mollom_class' configuration variable. Debug use only.
+ */
+function mollom($class = NULL) {
+  $instance = &drupal_static(__FUNCTION__);
+  if (!isset($class)) {
+    // @todo Testing mode configuration is not covered by tests.
+    if (variable_get('mollom_testing_mode', 0)) {
+      $class = 'MollomDrupalTest';
     }
-
-    $messages[] = array(
-      'Refreshed servers: %servers' => array('%servers' => implode(', ', $servers)),
-    );
-
-    // Store the list of servers in the database.
-    variable_set('mollom_servers', $servers);
-  }
-
-  if (is_array($servers)) {
-    // Send the request to the first server; if that fails, try the other
-    // servers in the list.
-    reset($servers);
-    while ($server = current($servers)) {
-      $result = xmlrpc($server . '/' . MOLLOM_API_VERSION, array(
-        $method => array($data + _mollom_authentication()),
-      ));
-
-      if ($result === FALSE && ($error = xmlrpc_error())) {
-        if ($error->code === MOLLOM_REFRESH) {
-          // Avoid endless loops.
-          if (!$refresh) {
-            $refresh = TRUE;
-
-            // Retrieve a new list of valid Mollom servers.
-            $servers = _mollom_retrieve_server_list();
-            // If API keys are invalid, the XML-RPC error code is returned.
-            // To reach this, we must have had a server list (and therefore
-            // valid keys) before, so we do not immediately return (like above),
-            // but instead trigger the fallback mode.
-            if (!is_array($servers)) {
-              break;
-            }
-
-            // Reset the list of servers to restart from the first server.
-            reset($servers);
-
-            // Update the server list.
-            variable_set('mollom_servers', $servers);
-
-            $messages[] = array(
-              'Refreshed servers: %servers' => array('%servers' => implode(', ', $servers)),
-            );
-          }
-        }
-        elseif ($error->code === MOLLOM_REDIRECT) {
-          // Try the next server in the list.
-          $next = next($servers);
-
-          $messages[] = array(
-            'Server %server redirected to: %next.' => array('%server' => $server, '%next' => $next),
-          );
-        }
-        else {
-          $messages[] = array(
-            'Error @errno from %server for %method: %message' => array(
-              '@errno' => $error->code,
-              '%server' => $server,
-              '%method' => $method,
-              '%message' => $error->message,
-            ),
-            'Data:<pre>@data</pre>' => array('@data' => $data),
-          );
-
-          // Instantly return upon a 'real' error.
-          if ($error->code === MOLLOM_ERROR) {
-            _mollom_watchdog_multiple($messages, WATCHDOG_ERROR);
-            return MOLLOM_ERROR;
-          }
-          // Otherwise, try the next server.
-          next($servers);
-        }
-      }
-      else {
-        _mollom_watchdog_multiple($messages, WATCHDOG_DEBUG);
-        return $result;
-      }
+    else {
+      $class = variable_get('mollom_class', 'MollomDrupal');
     }
   }
-
-  // If none of the servers worked, activate the fallback mechanism.
-  // @todo mollom() can be invoked outside of form processing. _mollom_fallback()
-  //   unconditionally invokes form_set_error(), which always displays the
-  //   fallback error message. Ideally, we would pass a $verbose argument to
-  //   _mollom_fallback(), but for that, we'd have to know here already.
-  //   Consequently, mollom() would need that $verbose argument. In the end, we
-  //   likely want to either embed the fallback handling into form processing,
-  //   or introduce a new helper function that is invoked instead of mollom()
-  //   during form processing.
-  if ($method != 'mollom.verifyKey') {
-    _mollom_fallback();
+  // If there is no instance yet or if it is not of the desired class, create a
+  // new one.
+  if (!isset($instance) || !($instance instanceof $class)) {
+    $instance = new $class();
   }
-
-  // If everything failed, we reset the server list to force Mollom to request
-  // a new list.
-  variable_del('mollom_servers');
-
-  // Report this error.
-  $messages[] = array(
-    'All servers unreachable or returning errors. The server list was emptied.' => array(),
-  );
-  _mollom_watchdog_multiple($messages, WATCHDOG_ERROR);
-
-  return NETWORK_ERROR;
+  return $instance;
 }
 
 /**
@@ -2025,65 +1884,41 @@ function _mollom_watchdog_multiple($messages, $severity) {
 }
 
 /**
- * Returns version information to send with mollom.verifyKey.
- *
- * Retrieves platform and module version information for mollom.verifyKey, which
- * is normally invoked on Mollom's administration pages only.
- *
- * This information is solely used to speed up support requests and technical
- * inquiries. The data may also be aggregated to help the Mollom staff to make
- * decisions on new features or the necessity of back-porting improved
- * functionality to older versions.
- *
- * @return
- *   An array containing:
- *   - platform_name: The name of the Drupal distribution; i.e., "Drupal".
- *   - platform_version: The version of Drupal; e.g., "7.0".
- *   - client_name: The module name; i.e., "Mollom".
- *   - client_version: The version of the module; e.g., "7.x-1.0".
- *
- * @see _mollom_status()
- */
-function _mollom_get_version() {
-  if ($cache = cache_get('mollom_version')) {
-    return $cache->data;
-  }
-
-  // Retrieve Drupal distribution and installation profile information.
-  $profile = drupal_get_profile();
-  $profile_info = system_get_info('module', $profile) + array(
-    'distribution_name' => 'Drupal',
-    'version' => VERSION,
-  );
-
-  // Retrieve Mollom module information.
-  $mollom_info = system_get_info('module', 'mollom');
-  if (empty($mollom_info['version'])) {
-    // Manually build a module version string for repository checkouts.
-    $mollom_info['version'] = DRUPAL_CORE_COMPATIBILITY . '-1.x-dev';
-  }
-
-  $data = array(
-    'platform_name' => $profile_info['distribution_name'],
-    'platform_version' => $profile_info['version'],
-    'client_name' => $mollom_info['name'],
-    'client_version' => $mollom_info['version'],
-  );
-  cache_set('mollom_version', $data);
-
-  return $data;
-}
-
-/**
  * Send feedback to Mollom.
- */
-function _mollom_send_feedback($session_id, $feedback = 'spam') {
-  $result = mollom('mollom.sendFeedback', array(
-    'session_id' => $session_id,
-    'feedback' => $feedback,
+ *
+ * @param $data
+ *   A Mollom data record containing one or both of:
+ *   - contentId: The content ID to send feedback for.
+ *   - captchaId: The CAPTCHA ID to send feedback for.
+ * @param $reason
+ *   The feedback to send, one of 'spam', 'profanity', 'quality', 'unwanted',
+ *   'approve', 'delete'.
+ */
+function _mollom_send_feedback($data, $reason = 'spam') {
+  if (!empty($data->captchaId)) {
+    $params['captchaId'] = $data->captchaId;
+    $resource = 'CAPTCHA';
+    $id = $data->captchaId;
+  }
+  // In case we also have a contentId, also pass that, and override $resource
+  // and $id for the log message.
+  if (!empty($data->contentId)) {
+    $params['contentId'] = $data->contentId;
+    $resource = 'content';
+    $id = $data->contentId;
+  }
+  if (!isset($id)) {
+    return FALSE;
+  }
+  $result = mollom()->sendFeedback($params + array(
+    'reason' => $reason,
   ));
   _mollom_watchdog(array(
-    'Reported %feedback for session id %session.' => array('%session' => $session_id, '%feedback' => $feedback),
+    'Reported %feedback for @resource %id.' => array(
+      '%feedback' => $reason,
+      '@resource' => $resource,
+      '%id' => $id,
+    ),
   ));
   return $result;
 }
@@ -2116,8 +1951,8 @@ function mollom_get_statistics($refresh = FALSE) {
       ));
 
       foreach ($statistics as $statistic) {
-        $result = mollom('mollom.getStatistics', array('type' => $statistic));
-        if ($result === NETWORK_ERROR || $result === MOLLOM_ERROR) {
+        $result = mollom()->getStatistics(array('type' => $statistic));
+        if ($result === Mollom::NETWORK_ERROR || $result === Mollom::AUTH_ERROR) {
           // If there was an error, stop fetching statistics and store FALSE
           // in the cache. This will help prevent from making unnecessary
           // requests to Mollom if the service is down or the server cannot
@@ -2174,66 +2009,69 @@ function mollom_field_extra_fields() {
 /**
  * Get the HTML markup for a Mollom CAPTCHA.
  *
- * @param $type
- *   The CAPTCHA type to retrieve, e.g. 'image' or 'audio'.
- * @param $data
- *   An optional array of parameters to send to Mollom when requesting the
- *   CAPTCHA.
+ * @param $form_state
+ *   The current state of a form.
  *
  * @return
- *   An array with the following key/value pairs:
- *     - 'data': An array of parameters sent to Mollom when requesting the
- *       CAPTCHA.
- *     - 'response': An array with the response from Mollom.
- *     - 'markup': The markup of the CAPTCHA HTML.
- */
-function mollom_get_captcha($type, array $data = array()) {
-  $data += array(
-    'author_ip' => ip_address(),
-    'ssl' => isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] == 'on',
-  );
+ *   The markup of the CAPTCHA HTML.
+ */
+function mollom_get_captcha(&$form_state) {
+  $key = 'captcha_url_' . $form_state['mollom']['captcha_type'];
+  if (empty($form_state['mollom']['response'][$key])) {
+    $data = array(
+      'type' => $form_state['mollom']['captcha_type'],
+      'authorIp' => ip_address(),
+      'ssl' => (int) (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] == 'on'),
+    );
+    if (!empty($form_state['mollom']['response']['content']['id'])) {
+      $data['contentId'] = $form_state['mollom']['response']['content']['id'];
+    }
+    $result = mollom()->getCaptcha($data);
+
+    if (isset($result['url'])) {
+      $url = $result['url'];
+      $form_state['mollom']['response'][$key] = $url;
+      $form_state['mollom']['response']['captcha']['id'] = $result['id'];
+    }
+    else {
+      return '';
+    }
+  }
+  else {
+    $url = $form_state['mollom']['response'][$key];
+  }
 
   // @todo Convert these to actual theme functions?
   $output = '';
-  switch ($type) {
+  switch ($form_state['mollom']['captcha_type']) {
     case 'audio':
-      $response = mollom('mollom.getAudioCaptcha', $data);
-      if ($response) {
-        $source = url(base_path() . drupal_get_path('module', 'mollom') . '/mollom-captcha-player.swf', array(
-          'query' => array('url' => $response['url']),
-          'external' => TRUE,
-        ));
-        $output = '<object classid="clsid:d27cdb6e-ae6d-11cf-96b8-444553540000" codebase="http://download.macromedia.com/pub/shockwave/cabs/flash/swflash.cab#version=9,0,0,0" width="110" height="50">';
-        $output .= '<param name="allowFullScreen" value="false" />';
-        $output .= '<param name="movie" value="' . $source . '" />';
-        $output .= '<param name="loop" value="false" />';
-        $output .= '<param name="menu" value="false" />';
-        $output .= '<param name="quality" value="high" />';
-        $output .= '<param name="wmode" value="transparent" />';
-        $output .= '<param name="bgcolor" value="#ffffff" />';
-        $output .= '<embed src="' . $source . '" loop="false" menu="false" quality="high" wmode="transparent" bgcolor="#ffffff" width="110" height="50" align="baseline" allowScriptAccess="sameDomain" allowFullScreen="false" type="application/x-shockwave-flash" pluginspage="http://www.adobe.com/go/getflashplayer_de" />';
-        $output .= '</object>';
-
-        $output = '<span class="mollom-captcha-content mollom-audio-captcha">' . $output . '</span>';
-        $output .= ' (<a href="#" class="mollom-switch-captcha mollom-image-captcha">' . t('verify using image') . '</a>)';
-      }
+      $source = url(base_path() . drupal_get_path('module', 'mollom') . '/mollom-captcha-player.swf', array(
+        'query' => array('url' => $url),
+        'external' => TRUE,
+      ));
+      $output = '<object classid="clsid:d27cdb6e-ae6d-11cf-96b8-444553540000" codebase="http://download.macromedia.com/pub/shockwave/cabs/flash/swflash.cab#version=9,0,0,0" width="110" height="50">';
+      $output .= '<param name="allowFullScreen" value="false" />';
+      $output .= '<param name="movie" value="' . $source . '" />';
+      $output .= '<param name="loop" value="false" />';
+      $output .= '<param name="menu" value="false" />';
+      $output .= '<param name="quality" value="high" />';
+      $output .= '<param name="wmode" value="transparent" />';
+      $output .= '<param name="bgcolor" value="#ffffff" />';
+      $output .= '<embed src="' . $source . '" loop="false" menu="false" quality="high" wmode="transparent" bgcolor="#ffffff" width="110" height="50" align="baseline" allowScriptAccess="sameDomain" allowFullScreen="false" type="application/x-shockwave-flash" pluginspage="http://www.adobe.com/go/getflashplayer_de" />';
+      $output .= '</object>';
+
+      $output = '<span class="mollom-captcha-content mollom-audio-captcha">' . $output . '</span>';
+      $output .= ' (<a href="#" class="mollom-switch-captcha mollom-image-captcha">' . t('verify using image') . '</a>)';
       break;
 
     case 'image':
-      $response = mollom('mollom.getImageCaptcha', $data);
-      if ($response) {
-        $captcha = theme('image', array('path' => url($response['url']), 'alt' => t('Type the characters you see in this picture.'), 'getsize' => FALSE));
-        $output = '<span class="mollom-captcha-content mollom-image-captcha">' . $captcha . '</span>';
-        $output .= ' (<a href="#" class="mollom-switch-captcha mollom-audio-captcha">' . t('verify using audio') . '</a>)';
-      }
+      $captcha = theme('image', array('path' => $url, 'alt' => t('Type the characters you see in this picture.'), 'getsize' => FALSE));
+      $output = '<span class="mollom-captcha-content mollom-image-captcha">' . $captcha . '</span>';
+      $output .= ' (<a href="#" class="mollom-switch-captcha mollom-audio-captcha">' . t('verify using audio') . '</a>)';
       break;
   }
 
-  return array(
-    'data' => $data,
-    'response' => $response,
-    'markup' => $output,
-  );
+  return $output;
 }
 
 /**
@@ -2256,11 +2094,19 @@ function mollom_mail_alter(&$message) {
  * Add the 'Report as innapropriate' link to an e-mail message.
  */
 function mollom_mail_add_report_link(&$message) {
-  if (!empty($GLOBALS['mollom']['response']['session_id'])) {
-    $mollom = $GLOBALS['mollom'];
+  $mollom = $GLOBALS['mollom'];
+  if (!empty($mollom['response']['content']['id']) || !empty($mollom['response']['captcha']['id'])) {
     $data = (object) $mollom['response'];
-    $data->entity = 'session';
-    $data->id = $mollom['response']['session_id'];
+    if (!empty($mollom['response']['content']['id'])) {
+      $data->entity = 'mollom_content';
+      $data->id = $data->content['id'];
+      $data->contentId = $data->content['id'];
+    }
+    else {
+      $data->entity = 'mollom_captcha';
+      $data->id = $data->captcha['id'];
+      $data->captchaId = $data->captcha['id'];
+    }
     $data->form_id = $mollom['form_id'];
     mollom_data_save($data);
     $report_link = t('Report as inappropriate: @link', array(
diff --git mollom.pages.inc mollom.pages.inc
index 1880092..15bce77 100644
--- mollom.pages.inc
+++ mollom.pages.inc
@@ -12,18 +12,22 @@
  *   The new CAPTCHA type to retrieve, e.g. 'image' or 'audio'.
  * @param $form_build_id
  *   The internal form build id of the form to update the CAPTCHA for.
- * @param $mollom_session_id
- *   The last known Mollom session id contained in the form.
+ * @param $captchaId
+ *   The last known Mollom CAPTCHA ID contained in the form.
  *
  * @return
  *   A JSON array containing:
  *   - content: The HTML markup for the new CAPTCHA.
- *   - session_id: The Mollom session id for the new CAPTCHA.
+ *   - captchaId: The ID for the new CAPTCHA.
  *
  * @todo Add error handling.
  */
-function mollom_captcha_js($type, $form_build_id, $mollom_session_id) {
-  $captcha = mollom_get_captcha($type, array('session_id' => $mollom_session_id));
+function mollom_captcha_js($type, $form_build_id, $captchaId) {
+  $dummy_state['mollom'] = array(
+    'captcha_type' => $type,
+  );
+  $dummy_state['mollom']['response']['captcha']['captchaId'] = $captchaId;
+  $captcha = mollom_get_captcha($dummy_state);
 
   // Update cached session id in the cached $form_state.
   // We rely on native form caching of Form API to store our Mollom session
@@ -36,20 +40,21 @@ function mollom_captcha_js($type, $form_build_id, $mollom_session_id) {
   // id. Therefore, we need to update the session id in the cached $form_state.
   // @todo Replace the entire CAPTCHA switch/refresh with new AJAX framework
   //   functionality.
-  if (!empty($captcha['response']['session_id'])) {
+  if (!empty($dummy_state['mollom']['response']['session_id'])) {
     if ($cache = cache_get('form_state_' . $form_build_id, 'cache_form')) {
       $form_state = $cache->data;
-      $form_state['mollom']['response']['session_id'] = $captcha['response']['session_id'];
+      $form_state['mollom']['response']['captcha']['captchaId'] = $dummy_state['mollom']['response']['captcha']['captchaId'];
+      // @todo Double-check $cid.
       cache_set('form_state_' . $form_build_id, $form_state, 'cache_form', REQUEST_TIME + 21600);
-      // After successfully updating the cache, replace the original session id.
-      $mollom_session_id = $captcha['response']['session_id'];
+      // After successfully updating the cache, replace the original ID.
+      $captchaId = $form_state['mollom']['response']['captcha']['captchaId'];
     }
   }
 
   // Return new content and new session_id via JSON.
   $data = array(
-    'content' => $captcha['markup'],
-    'session_id' => $mollom_session_id,
+    'content' => $captcha,
+    'captchaId' => $captchaId,
   );
   drupal_json_output($data);
   drupal_exit();
@@ -61,10 +66,9 @@ function mollom_captcha_js($type, $form_build_id, $mollom_session_id) {
  * @param $entity
  *   The entity type of the data to report, e.g. 'node' or 'comment'.
  * @param $id
- *   The entity id the data belongs to. If 'session' is passed as $entity, then
- *   $id is assumed to be a Mollom session_id, as returned by Mollom servers,
- *   which should only be used to report session data that was not stored for an
- *   entity in the database (such as contact form submissions).
+ *   The entity id the data belongs to.
+ *
+ * @see mollom_report_access()
  */
 function mollom_report_form($form, &$form_state, $entity, $id) {
   $form['entity'] = array(
@@ -99,17 +103,11 @@ function mollom_report_form_submit($form, &$form_state) {
     $id = $form_state['values']['id'];
 
     // Load the Mollom session data.
-    if ($entity == 'session') {
-      $data = new stdClass;
-      $data->session_id = $id;
-    }
-    else {
-      $data = mollom_data_load($entity, $id);
-    }
+    $data = mollom_data_load($entity, $id);
 
     // Send feedback to Mollom, if we have session data.
-    if (!empty($data->session_id) && !empty($form_state['values']['mollom']['feedback'])) {
-      if (_mollom_send_feedback($data->session_id, $form_state['values']['mollom']['feedback'])) {
+    if ((!empty($data->contentId) || !empty($data->captchaId)) && !empty($form_state['values']['mollom']['feedback'])) {
+      if (_mollom_send_feedback($data, $form_state['values']['mollom']['feedback'])) {
         drupal_set_message(t('The content was successfully reported as inappropriate.'));
       }
     }
diff --git tests/mollom.class.test tests/mollom.class.test
new file mode 100644
index 0000000..ed65fe7
--- /dev/null
+++ tests/mollom.class.test
@@ -0,0 +1,218 @@
+<?php
+
+/**
+ * @file
+ * Unit tests for Mollom class.
+ *
+ * @todo After final Mollom class commit, move all low-level tests from
+ *   mollom.test into this file.
+ */
+
+/**
+ * Tests Mollom class functionality.
+ */
+class MollomClassUnitTestCase extends DrupalUnitTestCase {
+  public static function getInfo() {
+    return array(
+      'name' => 'Mollom class',
+      'description' => 'Tests Mollom class functionality.',
+      'group' => 'Mollom',
+    );
+  }
+
+  function setUp() {
+    parent::setUp();
+    // DrupalUnitTestCase does not autoload classes for whatever reason.
+    module_load_include('inc', 'mollom');
+  }
+
+  /**
+   * Asserts that two values belonging to the same variable are equal.
+   *
+   * Checks to see whether two values, which belong to the same variable name or
+   * identifier, are equal and logs a readable assertion message.
+   *
+   * @param $name
+   *   A name or identifier to use in the assertion message.
+   * @param $first
+   *   The first value to check.
+   * @param $second
+   *   The second value to check.
+   *
+   * @return
+   *   TRUE if the assertion succeeded, FALSE otherwise.
+   *
+   * @see MollomWebTestCase::assertNotSame()
+   *
+   * @todo D8: Move into core. This improved assertEqual() did not get into D7,
+   *   since the function signature differs and it's plenty of work to manually
+   *   update all assertEqual() invocations throughout all tests.
+   */
+  protected function assertSame($name, $first, $second) {
+    $message = t("@name: @first is equal to @second.", array(
+      '@name' => $name,
+      '@first' => var_export($first, TRUE),
+      '@second' => var_export($second, TRUE),
+    ));
+    $this->assertEqual($first, $second, $message);
+  }
+
+  /**
+   * Asserts that two values belonging to the same variable are not equal.
+   *
+   * Checks to see whether two values, which belong to the same variable name or
+   * identifier, are not equal and logs a readable assertion message.
+   *
+   * @param $name
+   *   A name or identifier to use in the assertion message.
+   * @param $first
+   *   The first value to check.
+   * @param $second
+   *   The second value to check.
+   *
+   * @return
+   *   TRUE if the assertion succeeded, FALSE otherwise.
+   *
+   * @see MollomWebTestCase::assertSame()
+   */
+  protected function assertNotSame($name, $first, $second) {
+    $message = t("@name: @first is not equal to @second.", array(
+      '@name' => $name,
+      '@first' => var_export($first, TRUE),
+      '@second' => var_export($second, TRUE),
+    ));
+    $this->assertNotEqual($first, $second, $message);
+  }
+
+  /**
+   * Tests Mollom::httpBuildQuery().
+   */
+  function testHttpBuildQuery() {
+    $input = array('foo' => 1, 'bar' => 2);
+    $expected = 'foo=1&bar=2';
+    $this->assertSame(var_export($input, TRUE), Mollom::httpBuildQuery($input), $expected);
+
+    $input = array('checks' => array('foo' => 'spam', 'bar' => 'profanity'));
+    $expected = 'checks[foo]=spam&checks[bar]=profanity';
+    $this->assertSame(var_export($input, TRUE), Mollom::httpBuildQuery($input), $expected);
+
+    $input = array('checks' => array('spam', 'profanity'));
+    $expected = 'checks=spam&checks=profanity';
+    $this->assertSame(var_export($input, TRUE), Mollom::httpBuildQuery($input), $expected);
+
+    $input = array('checks' => array(array('spam'), array('profanity')));
+    $expected = 'checks=spam&checks=profanity';
+    $this->assertSame(var_export($input, TRUE), Mollom::httpBuildQuery($input), $expected);
+
+    $input = array('checks' => array('spam', ''));
+    $expected = 'checks=spam&checks=';
+    $this->assertSame(var_export($input, TRUE), Mollom::httpBuildQuery($input), $expected);
+
+    $input = array('checks' => 'spam');
+    $expected = 'checks=spam';
+    $this->assertSame(var_export($input, TRUE), Mollom::httpBuildQuery($input), $expected);
+  }
+
+  /**
+   * Tests Mollom::httpParseQuery().
+   */
+  function testHttpParseQuery() {
+    $input = 'foo=1&bar=2';
+    $expected = array('foo' => 1, 'bar' => 2);
+    $this->assertSame($input, Mollom::httpParseQuery($input), $expected);
+
+    $input = 'checks=spam&checks=profanity';
+    $expected = array('checks' => array('spam', 'profanity'));
+    $this->assertSame($input, Mollom::httpParseQuery($input), $expected);
+
+    // Mollom::httpParseQuery() does not attempt to work transparently. Thus,
+    // multiple parameter names containing brackets itself (regular PHP syntax)
+    // will lead to an "unexpected" result. Although it wouldn't be hard to add
+    // support for this, there's currently no need for it.
+    $input = 'checks[]=spam&checks[]=profanity';
+    $expected = array('checks' => array(array('spam'), array('profanity')));
+    $this->assertSame($input, Mollom::httpParseQuery($input), $expected);
+
+    $input = 'checks=spam&checks=';
+    $expected = array('checks' => array('spam', ''));
+    $this->assertSame($input, Mollom::httpParseQuery($input), $expected);
+
+    $input = 'checks=spam&checks';
+    $expected = array('checks' => array('spam', ''));
+    $this->assertSame($input, Mollom::httpParseQuery($input), $expected);
+
+    $input = 'checks=spam&';
+    $expected = array('checks' => 'spam');
+    $this->assertSame($input, Mollom::httpParseQuery($input), $expected);
+
+    $input = 'checks=spam';
+    $expected = array('checks' => 'spam');
+    $this->assertSame($input, Mollom::httpParseQuery($input), $expected);
+  }
+
+  /**
+   * Tests Mollom::parseXML().
+   */
+  function testParseXML() {
+    $header = '<?xml version="1.0"?>';
+
+    $input = $header . <<<EOF
+<response>
+  <code>0</code>
+  <message>Foo.</message>
+  <content>
+    <contentId>321</contentId>
+    <languages>
+      <language>
+        <code>en</code>
+        <confidence>1.0</confidence>
+      </language>
+      <language>
+        <code>de</code>
+        <confidence>0.5</confidence>
+      </language>
+    </languages>
+  </content>
+</response>
+EOF;
+    $expected = array(
+      'code' => 0,
+      'message' => 'Foo.',
+      'content' => array(
+        'contentId' => 321,
+        'languages' => array(
+          array('code' => 'en', 'confidence' => 1.0),
+          array('code' => 'de', 'confidence' => 0.5),
+        ),
+      ),
+    );
+    $this->assertSame($input, Mollom::parseXML(new SimpleXmlIterator($input)), $expected);
+
+    $input = $header . <<<EOF
+<response>
+  <code>0</code>
+  <message></message>
+  <site>
+    <publicKey>321</publicKey>
+    <servers>
+      <server>http://foo</server>
+      <server>http://bar</server>
+    </servers>
+  </site>
+</response>
+EOF;
+    $expected = array(
+      'code' => 0,
+      'message' => '',
+      'site' => array(
+        'publicKey' => 321,
+        'servers' => array(
+          'http://foo',
+          'http://bar',
+        ),
+      ),
+    );
+    $this->assertSame($input, Mollom::parseXML(new SimpleXmlIterator($input)), $expected);
+  }
+}
+
diff --git tests/mollom.test tests/mollom.test
index 592858f..fdb8228 100644
--- tests/mollom.test
+++ tests/mollom.test
@@ -68,12 +68,12 @@ class MollomWebTestCase extends DrupalWebTestCase {
   /**
    * The public key used during testing.
    */
-  protected $public_key = '';
+  protected $public_key;
 
   /**
    * The private key used during testing.
    */
-  protected $private_key = '';
+  protected $private_key;
 
   /**
    * A boolean that is TRUE if the above keys are for a reseller account.
@@ -81,23 +81,50 @@ class MollomWebTestCase extends DrupalWebTestCase {
   protected $is_reseller = FALSE;
 
   /**
+   * The Mollom client class implementation to use.
+   *
+   * By default, we use MollomDrupalTest and test against production Mollom
+   * testing servers.
+   * Assign MollomDrupalTestLocal to test against local dummy/fake REST server.
+   *
+   * @see mollom.drupal.inc
+   *
+   * @var string
+   */
+  protected $mollomClass = 'MollomDrupalTest';
+
+  /**
    * Set up an administrative user account and testing keys.
    */
   function setUp() {
     // Re-initialize stored session_id and watchdog messages.
-    $this->resetSessionID();
+    $this->resetResponseID();
     $this->messages = array();
 
     $modules = func_get_args();
     $modules = (isset($modules[0]) ? $modules[0] : array());
 
-    // If not explicitly disabled by a test, setup with Mollom and default admin
-    // user.
+    // Automatically enable local testing server implementation.
+    if (strstr($this->mollomClass, 'Local') && !in_array('mollom_test', $modules)) {
+      $modules[] = 'mollom_test';
+    }
+
+    // If not explicitly disabled by a test, setup with Mollom.
     if (empty($this->disableDefaultSetup)) {
       $modules[] = 'mollom';
-      $modules[] = 'dblog';
-      parent::setUp($modules);
+    }
+    $modules[] = 'dblog';
+    parent::setUp($modules);
+    variable_set('mollom_class', $this->mollomClass);
+
+    // D7's new default theme Bartik is bogus in various locations, which leads
+    // to failing tests.
+    // @todo Remove this override.
+    variable_set('theme_default', 'garland');
 
+    // If not explicitly disabled by a test, setup and validate testing keys,
+    // and create a default admin user.
+    if (empty($this->disableDefaultSetup)) {
       $permissions = array(
         'access administration pages',
         'administer mollom',
@@ -109,25 +136,15 @@ class MollomWebTestCase extends DrupalWebTestCase {
         $permissions[] = 'administer comments';
       }
       $this->admin_user = $this->drupalCreateUser($permissions);
-    }
-    else {
-      $modules[] = 'dblog';
-      parent::setUp($modules);
-    }
 
-    // D7's new default theme Bartik is bogus in various locations, which leads
-    // to failing tests.
-    // @todo Remove this override.
-    variable_set('theme_default', 'garland');
-
-    // If not explicitly disabled by a test, setup and validate testing keys.
-    if (empty($this->disableDefaultSetup)) {
       $this->setKeys();
       $this->assertValidKeys();
     }
   }
 
   function tearDown() {
+    // Delete the testing site.
+    $this->deleteKeys();
     // Capture any (remaining) watchdog messages.
     $this->assertMollomWatchdogMessages();
     parent::tearDown();
@@ -159,7 +176,7 @@ class MollomWebTestCase extends DrupalWebTestCase {
       ->orderBy('w.timestamp', 'ASC');
 
     // The comparison logic applied in this function is a bit confusing, since
-    // the values of the watchdog severity level constants in Drupal core are
+    // the values of watchdog severity level constants defined by RFC 3164 are
     // negated to their actual "severity level" meaning:
     // WATCHDOG_EMERGENCY is 0, WATCHDOG_NOTICE is 5, WATCHDOG_DEBUG is 7.
 
@@ -170,11 +187,18 @@ class MollomWebTestCase extends DrupalWebTestCase {
       // Only messages with a maximum severity of $max_severity or less severe
       // messages must pass. More severe messages need to fail. See note about
       // severity level constant values above.
+      $output = theme_dblog_message(array('event' => $row, 'link' => FALSE));
       if ($row->severity >= $max_severity) {
-        $this->pass(theme_dblog_message(array('event' => $row, 'link' => FALSE)), t('Watchdog'));
+        // Visually separate debug log messages from other messages.
+        if ($row->severity == WATCHDOG_DEBUG) {
+          $this->error($output, 'User notice');
+        }
+        else {
+          $this->pass($output, t('Watchdog'));
+        }
       }
       else {
-        $this->fail(theme_dblog_message(array('event' => $row, 'link' => FALSE)), t('Watchdog'));
+        $this->fail($output, t('Watchdog'));
       }
       // In case a severe message is expected, non-severe messages always pass,
       // since we would trigger a false positive test failure otherwise.
@@ -184,7 +208,7 @@ class MollomWebTestCase extends DrupalWebTestCase {
     }
     // Assert that there was a severe message, in case we expected one.
     if ($fail_expected && !$had_severe_message) {
-      $this->fail(t('Severe log message not found.'), t('Watchdog'));
+      $this->fail(t('Severe log message was found.'), t('Watchdog'));
     }
     // Delete processed watchdog messages.
     if (!empty($this->messages)) {
@@ -200,55 +224,102 @@ class MollomWebTestCase extends DrupalWebTestCase {
    * with a different Mollom server (due to a refreshed server list or being
    * redirected), then we will get a new session_id.
    *
-   * @param $session_id
-   *   A Mollom session_id of the last request, as contained in the XML-RPC
-   *   response.
+   * @param $type
+   *   The type of ID to assert; e.g., 'contentId', 'captchaId'.
+   * @param $id
+   *   The ID of $type in the last request, as returned from Mollom.
    */
-  protected function assertSessionID($session_id) {
+  protected function assertResponseID($type, $id) {
     // Check whether watchdog messages indicate a refresh or redirect.
     foreach ($this->messages as $message) {
       if ($message->message == 'Refreshed servers: %servers' || $message->message == 'Server %server redirected to: %next.') {
-        $this->resetSessionID();
+        // Reset all IDs.
+        $this->resetResponseID();
       }
     }
 
-    if (!isset($this->session_id)) {
+    if (!isset($this->responseIds[$type])) {
       // Use assertTrue() instead of pass(), to test !empty().
-      $this->assertTrue($session_id, t('New session_id: %session_id', array('%session_id' => $session_id)));
-      $this->session_id = $session_id;
+      $this->assertTrue($id, t('New %type: %id', array(
+        '%type' => $type,
+        '%id' => $id,
+      )));
+      $this->responseIds[$type] = $id;
     }
     else {
-      $this->assertSame('session_id', $session_id, $this->session_id);
+      $this->assertSame($type, $id, $this->responseIds[$type]);
     }
-    return $this->session_id;
+    return $this->responseIds[$type];
   }
 
   /**
    * Reset the statically cached Mollom session id.
+   *
+   * @param $type
+   *   The type of ID to reset; e.g., 'contentId', 'captchaId'.
    */
-  protected function resetSessionID() {
-    $this->session_id = NULL;
+  protected function resetResponseID($type = NULL) {
+    if (isset($type)) {
+      unset($this->responseIds[$type]);
+    }
+    else {
+      unset($this->responseIds);
+    }
   }
 
   /**
    * Assert a Mollom session id in a form.
    *
-   * This is a wrapper around assertSessionID() allows to assert that a proper
+   * This is a wrapper around assertResponseID() allows to assert that a proper
    * Mollom session id is found in the form contained in the internal browser
    * output. The usual flow is:
    * - drupalGet() or drupalPost() requests or submits a form.
    * - drupalGet() and drupalPost() invoke assertMollomWatchdogMessages()
    *   internally, which records all new watchdog messages.
-   * - This function, assertSessionIDInForm(), is invoked to assert that there
+   * - This function, assertResponseIDInForm(), is invoked to assert that there
    *   is a Mollom session id and, depending on the recorded watchdog messages,
    *   that it either equals the last known session id or the new session id is
    *   used for future comparisons in case of a server redirect.
    * - The return value of this function is used to invoke assertMollomData(),
    *   to verify that the proper session id was stored in the database.
+   *
+   * @param $type
+   *   The type of ID to assert; e.g., 'contentId', 'captchaId'.
    */
-  protected function assertSessionIDInForm() {
-    $session_id = $this->getFieldValueByName('mollom[session_id]');
-    return $this->assertSessionID($session_id);
+  protected function assertResponseIDInForm($type) {
+    $id = $this->getFieldValueByName('mollom[' . $type . ']');
+    return $this->assertResponseID($type, $id);
+  }
+
+  /**
+   * Creates a testing site and sets keys to class properties.
+   *
+   * @param $public
+   *   (optional) A public Mollom API key to use to create the site.
+   * @param $private
+   *   (optional) A private Mollom API key to use to create the site.
+   */
+  protected function createKeys($public = MOLLOM_TEST_PUBLIC_KEY, $private = MOLLOM_TEST_PRIVATE_KEY) {
+    $mollom = mollom();
+    // Hardcoded servers required, or any request will fail.
+    foreach ($mollom->serversInit as $server) {
+      $mollom->servers[] = $server . '/' . Mollom::API_VERSION;
+    }
+    // Dummy API keys required, or Mollom client class (rightfully) bails out.
+    $mollom->publicKey = MOLLOM_TEST_PUBLIC_KEY;
+    $mollom->privateKey = MOLLOM_TEST_PRIVATE_KEY;
+
+    $result = $mollom->createSite(array(
+      'url' => $GLOBALS['base_url'],
+      'email' => variable_get('site_mail', 'mollom-drupal-test@example.com'),
+    ));
+
+    // Set testing class properties.
+    $this->public_key = $result['publicKey'];
+    $this->private_key = $result['privateKey'];
+    // Set Mollom class properties; the instance may be re-used within the test.
+    $mollom->publicKey = $this->public_key;
+    $mollom->privateKey = $this->private_key;
   }
 
   /**
@@ -263,17 +334,15 @@ class MollomWebTestCase extends DrupalWebTestCase {
    *   otherwise.
    */
   protected function setKeys($public = MOLLOM_TEST_PUBLIC_KEY, $private = MOLLOM_TEST_PRIVATE_KEY, $reseller = MOLLOM_TEST_RESELLER_KEY) {
-    // Save internal properties.
-    $this->public_key = $public;
-    $this->private_key = $private;
+    // Create a testing site and use its keys.
+    $this->createKeys($public, $private);
+
+    // Set reseller property.
     $this->is_reseller = $reseller;
 
     // Set the module key settings.
-    variable_set('mollom_public_key', $public);
-    variable_set('mollom_private_key', $private);
-
-    // Enable testing mode.
-    variable_set('mollom_testing_mode', 1);
+    variable_set('mollom_public_key', $this->public_key);
+    variable_set('mollom_private_key', $this->private_key);
 
     // Delete any previously set Mollom servers to make sure we are using
     // the default ones.
@@ -291,6 +360,15 @@ class MollomWebTestCase extends DrupalWebTestCase {
   }
 
   /**
+   * Deletes the current testing site.
+   */
+  protected function deleteKeys() {
+    mollom()->deleteSite($this->public_key);
+    unset($this->public_key);
+    unset($this->private_key);
+  }
+
+  /**
    * Configure Mollom protection for a given form.
    *
    * @param $form_id
@@ -379,14 +457,20 @@ class MollomWebTestCase extends DrupalWebTestCase {
    *   The entity type to search for in {mollom}.
    * @param $id
    *   The entity id to search for in {mollom}.
-   * @param $session_id
-   *   (optional) The Mollom session id to assert additionally.
+   * @param $response_type
+   *   (optional) The type of ID to assert; e.g., 'contentId', 'captchaId'.
+   * @param $response_id
+   *   (optional) The ID of $type to assert additionally.
    */
-  protected function assertMollomData($entity, $id, $session_id = NULL) {
+  protected function assertMollomData($entity, $id, $response_type = '', $response_id = NULL) {
     $data = mollom_data_load($entity, $id);
-    $this->assertTrue($data->session_id, t('Mollom session data for %entity @id exists: <pre>@data</pre>', array('%entity' => $entity, '@id' => $id, '@data' => var_export($data, TRUE))));
-    if (isset($session_id)) {
-      $this->assertSame(t('Stored session id'), $data->session_id, $session_id);
+    $this->assertTrue($data->id, t('Mollom session data for %entity @id exists: <pre>@data</pre>', array(
+      '%entity' => $entity,
+      '@id' => $id,
+      '@data' => var_export($data, TRUE),
+    )));
+    if (isset($response_id)) {
+      $this->assertSame(t('Stored session id'), $data->$response_type, $response_id);
     }
     return $data;
   }
@@ -584,12 +668,16 @@ class MollomWebTestCase extends DrupalWebTestCase {
    *
    * @see MollomWebTestCase::resetServerRecords()
    * @see mollom_test_xmlrpc()
+   *
+   * @todo Needs update. May directly use the short name suffixes of testing
+   *   server API functions; i.e., 'content', 'captcha', 'blacklist', etc.
    */
   protected function getServerRecord($method = 'mollom.checkContent') {
     // Map the XML-RPC method name to the corresponding function callback name.
     drupal_load('module', 'mollom_test');
     $method_function_map = mollom_test_xmlrpc();
     $function = $method_function_map[$method];
+    $function = strtr($function, array('xmlrpc_' => ''));
 
     // Retrieve last recorded values.
     $storage = variable_get($function, array());
@@ -614,6 +702,7 @@ class MollomWebTestCase extends DrupalWebTestCase {
     drupal_load('module', 'mollom_test');
     $method_function_map = mollom_test_xmlrpc();
     $function = $method_function_map[$method];
+    $function = strtr($function, array('xmlrpc_' => ''));
 
     // Delete the variable.
     variable_del($function);
@@ -630,7 +719,7 @@ class MollomWebTestCase extends DrupalWebTestCase {
    *
    * @see DrupalWebTestCase->drupalGet()
    * @see MollomWebTestCase->assertMollomWatchdogMessages()
-   * @see MollomWebTestCase->assertSessionID()
+   * @see MollomWebTestCase->assertResponseID()
    */
   protected function drupalGet($path, array $options = array(), array $headers = array()) {
     $output = parent::drupalGet($path, $options, $headers);
@@ -649,7 +738,7 @@ class MollomWebTestCase extends DrupalWebTestCase {
    *     negate the watchdog message severity assertion.
    *
    * @see MollomWebTestCase->assertMollomWatchdogMessages()
-   * @see MollomWebTestCase->assertSessionID()
+   * @see MollomWebTestCase->assertResponseID()
    * @see DrupalWebTestCase->drupalPost()
    */
   protected function drupalPost($path, $edit, $submit, array $options = array(), array $headers = array(), $form_html_id = NULL, $extra_post = NULL) {
@@ -731,10 +820,6 @@ class MollomInstallationTestCase extends MollomWebTestCase {
   }
 
   function setUp() {
-    // Re-initialize stored session_id and watchdog messages.
-    $this->resetSessionID();
-    $this->messages = array();
-
     $this->disableDefaultSetup = TRUE;
     parent::setUp(array('comment'));
 
@@ -834,11 +919,14 @@ class MollomInstallationTestCase extends MollomWebTestCase {
     $this->assertText(t('The Mollom servers could not be contacted. Please make sure that your web server can make outgoing HTTP requests.'));
     $this->assertNoText($this->fallback_message, t('Fallback message not found.'));
 
+    // Create a testing site on backend to have some API keys.
+    $this->createKeys();
+
     // Verify that valid keys work.
     $this->drupalGet('admin/config/content/mollom/settings', array('watchdog' => WATCHDOG_EMERGENCY));
     $edit = array(
-      'mollom_public_key' => MOLLOM_TEST_PUBLIC_KEY,
-      'mollom_private_key' => MOLLOM_TEST_PRIVATE_KEY,
+      'mollom_public_key' => $this->public_key,
+      'mollom_private_key' => $this->private_key,
       'mollom_testing_mode' => 1,
     );
     $this->drupalPost(NULL, $edit, t('Save configuration'));
@@ -881,153 +969,305 @@ class MollomResponseTestCase extends MollomWebTestCase {
   }
 
   /**
+   * Tests Site API.
+   */
+  function testSiteAPI() {
+    $mollom = mollom();
+    $info = $mollom->getClientInformation();
+
+    // Create a new site.
+    $site = array(
+      'url' => 'example.com',
+      'email' => 'mollom@example.com',
+    );
+    $result = $mollom->createSite($site);
+    $this->assertMollomWatchdogMessages();
+    $this->assertTrue(!empty($result['publicKey']), 'publicKey found.');
+    $this->assertTrue(!empty($result['privateKey']), 'privateKey found.');
+    $this->assertSame('url', $result['url'], $site['url']);
+    $this->assertSame('email', $result['email'], $site['email']);
+    $this->assertTrue(!isset($result['platformName']), 'platformName not found.');
+    $this->assertTrue(!isset($result['platformVersion']), 'platformVersion not found.');
+    $this->assertTrue(!isset($result['clientName']), 'clientName not found.');
+    $this->assertTrue(!isset($result['clientVersion']), 'clientVersion not found.');
+    $this->assertTrue(!empty($result['servers']) && is_array($result['servers']), 'Server list found.');
+
+    $site = $result;
+    $mollom->publicKey = $site['publicKey'];
+    $mollom->privateKey = $site['privateKey'];
+
+    // Verify that getSite() response equals the createSite() response.
+    $result = $mollom->getSite();
+    $this->assertMollomWatchdogMessages();
+    $this->assertSame('publicKey', $result['publicKey'], $site['publicKey']);
+    $this->assertSame('privateKey', $result['privateKey'], $site['privateKey']);
+    $this->assertSame('url', $result['url'], $site['url']);
+    $this->assertSame('email', $result['email'], $site['email']);
+    $this->assertTrue(!isset($result['platformName']), 'platformName not found.');
+    $this->assertTrue(!isset($result['platformVersion']), 'platformVersion not found.');
+    $this->assertTrue(!isset($result['clientName']), 'clientName not found.');
+    $this->assertTrue(!isset($result['clientVersion']), 'clientVersion not found.');
+    $this->assertSame('servers', $result['servers'], $site['servers']);
+
+    // Test that verifying keys updates client information.
+    $result = $mollom->verifyKey();
+    $this->assertMollomWatchdogMessages();
+    $this->assertIdentical($result, TRUE, 'Site was updated.');
+
+    $result = $mollom->getSite();
+    $this->assertMollomWatchdogMessages();
+    $this->assertSame('publicKey', $result['publicKey'], $site['publicKey']);
+    $this->assertSame('privateKey', $result['privateKey'], $site['privateKey']);
+    $this->assertSame('url', $result['url'], $site['url']);
+    $this->assertSame('email', $result['email'], $site['email']);
+    $this->assertSame('platformName', $result['platformName'], $info['platformName']);
+    $this->assertSame('platformVersion', $result['platformVersion'], $info['platformVersion']);
+    $this->assertSame('clientName', $result['clientName'], $info['clientName']);
+    $this->assertSame('clientVersion', $result['clientVersion'], $info['clientVersion']);
+    $this->assertSame('servers', $result['servers'], $site['servers']);
+
+    // Verify that the site is listed.
+    // FIXME: Site listing not supported by backend yet.
+    /*
+    $result = $mollom->getSites();
+    $this->assertMollomWatchdogMessages();
+    $found = FALSE;
+    foreach ($result as $record) {
+      if ($record['publicKey'] == $site['publicKey']) {
+        $found = TRUE;
+      }
+    }
+    $this->assertTrue($found, 'Site record was found in site list.');
+    */
+
+    // Verify that the site can be deleted.
+    $result = $mollom->deleteSite($site['publicKey']);
+    $this->assertMollomWatchdogMessages();
+    $this->assertIdentical($result, TRUE, 'Site was deleted.');
+
+    // Verify that the site no longer appears in site list.
+    $mollom->publicKey = $this->public_key;
+    $mollom->privateKey = $this->private_key;
+    // FIXME: Site listing not supported by backend yet.
+    /*
+    $result = $mollom->getSites();
+    $this->assertMollomWatchdogMessages();
+    $found = FALSE;
+    foreach ($result as $record) {
+      if ($record['publicKey'] == $site['publicKey']) {
+        $found = TRUE;
+      }
+    }
+    $this->assertFalse($found, 'Deleted site no longer exists.');
+    */
+
+    // Verify that retrieving the deleted site yields a 404.
+    $result = $mollom->getSite($site['publicKey']);
+    $this->assertMollomWatchdogMessages(WATCHDOG_EMERGENCY);
+    $this->assertEqual($result, 404, 'Attempt to get deleted site throws 404.');
+
+    // Verify that authentication fails.
+    $mollom->publicKey = $site['publicKey'];
+    $mollom->privateKey = $site['privateKey'];
+    $result = $mollom->getSite();
+    $this->assertMollomWatchdogMessages(WATCHDOG_EMERGENCY);
+    $this->assertEqual($mollom->lastResponseCode, 404, 'Attempt to authenticate with deleted site keys returns 404.');
+
+    // Restore keys for tearDown().
+    $mollom->publicKey = $this->public_key;
+    $mollom->privateKey = $this->private_key;
+  }
+
+  /**
    * Tests mollom.checkContent().
    */
   function testCheckContent() {
+    $mollom = mollom();
     $data = array(
-      'author_name' => $this->admin_user->name,
-      'author_mail' => $this->admin_user->mail,
-      'author_id' => $this->admin_user->uid,
-      'author_ip' => ip_address(),
+      'authorName' => $this->admin_user->name,
+      'authorMail' => $this->admin_user->mail,
+      'authorId' => $this->admin_user->uid,
+      'authorIp' => ip_address(),
     );
 
     // Ensure proper response for 'ham' submissions.
     // By default (i.e., omitting 'checks') we expect spam and quality checking
     // only.
-    $data['post_body'] = 'ham';
-    $result = mollom('mollom.checkContent', $data);
+    $data['postBody'] = 'ham';
+    $result = $mollom->checkContent($data);
     $this->assertMollomWatchdogMessages();
-    $this->assertSame('spam', $result['spam'], MOLLOM_ANALYSIS_HAM);
-    $this->assertSame('quality', $result['quality'], 1);
-    $this->assertTrue(!isset($result['profanity']), 'profanity not returned.');
-    $session_id = $this->assertSessionID($result['session_id']);
+    $this->assertSame('spamScore', $result['spamScore'], 0.0);
+    $this->assertSame('spamClassification', $result['spamClassification'], 'ham');
+    $this->assertSame('qualityScore', $result['qualityScore'], 1.0);
+    $this->assertTrue(!isset($result['profanityScore']), 'profanityScore not returned.');
+    $data['id'] = $this->assertResponseID('contentId', $result['id']);
 
     // Ensure proper response for 'spam' submissions, re-using session_id.
-    $data['post_body'] = 'spam';
-    $data['session_id'] = $session_id;
-    $result = mollom('mollom.checkContent', $data);
+    $data['postBody'] = 'spam';
+    $result = $mollom->checkContent($data);
     $this->assertMollomWatchdogMessages();
-    $this->assertSame('spam', $result['spam'], MOLLOM_ANALYSIS_SPAM);
-    $this->assertSame('quality', $result['quality'], 0);
-    $this->assertTrue(!isset($result['profanity']), 'profanity not returned.');
-    $session_id = $this->assertSessionID($result['session_id']);
+    $this->assertSame('spamScore', $result['spamScore'], 1.0);
+    $this->assertSame('spamClassification', $result['spamClassification'], 'spam');
+    $this->assertSame('qualityScore', $result['qualityScore'], 0.0);
+    $this->assertTrue(!isset($result['profanityScore']), 'profanityScore not returned.');
+    $data['id'] = $this->assertResponseID('contentId', $result['id']);
 
     // Ensure proper response for 'unsure' submissions, re-using session_id.
-    $data['post_body'] = 'unsure';
-    $data['session_id'] = $session_id;
-    $result = mollom('mollom.checkContent', $data);
+    $data['postBody'] = 'unsure';
+    $result = $mollom->checkContent($data);
     $this->assertMollomWatchdogMessages();
-    $this->assertSame('spam', $result['spam'], MOLLOM_ANALYSIS_UNSURE);
-    $this->assertSame('quality', $result['quality'], 0.5);
-    $this->assertTrue(!isset($result['profanity']), 'profanity not returned.');
-    $session_id = $this->assertSessionID($result['session_id']);
+    $this->assertSame('spamScore', $result['spamScore'], 0.5);
+    $this->assertSame('spamClassification', $result['spamClassification'], 'unsure');
+    $this->assertSame('qualityScore', $result['qualityScore'], 0.5);
+    $this->assertTrue(!isset($result['profanityScore']), 'profanityScore not returned.');
+    $data['id'] = $this->assertResponseID('contentId', $result['id']);
 
     // Additionally enable profanity checking.
-    $data['post_body'] = 'spam profanity';
-    $data['checks'] = 'spam,quality,profanity';
-    $data['session_id'] = $session_id;
-    $result = mollom('mollom.checkContent', $data);
+    $data['postBody'] = 'spam profanity';
+    $data['checks'] = array('spam', 'quality', 'profanity');
+    $result = $mollom->checkContent($data);
     $this->assertMollomWatchdogMessages();
-    $this->assertSame('spam', $result['spam'], MOLLOM_ANALYSIS_SPAM);
-    $this->assertSame('quality', $result['quality'], 0);
-    $this->assertSame('profanity', $result['profanity'], 1);
-    $session_id = $this->assertSessionID($result['session_id']);
+    $this->assertSame('spamScore', $result['spamScore'], 1.0);
+    $this->assertSame('spamClassification', $result['spamClassification'], 'spam');
+    $this->assertSame('qualityScore', $result['qualityScore'], 0.0);
+    $this->assertSame('profanityScore', $result['profanityScore'], 1.0);
+    $data['id'] = $this->assertResponseID('contentId', $result['id']);
 
     // Change the string to contain profanity only.
-    $data['post_body'] = 'profanity';
-    $data['checks'] = 'spam,quality,profanity';
-    $data['session_id'] = $session_id;
-    $result = mollom('mollom.checkContent', $data);
+    $data['postBody'] = 'profanity';
+    $data['checks'] = array('spam', 'quality', 'profanity');
+    $result = $mollom->checkContent($data);
     $this->assertMollomWatchdogMessages();
-    $this->assertSame('spam', $result['spam'], MOLLOM_ANALYSIS_UNSURE);
-    $this->assertSame('quality', $result['quality'], 0);
-    $this->assertSame('profanity', $result['profanity'], 1);
-    $session_id = $this->assertSessionID($result['session_id']);
+    $this->assertSame('spamScore', $result['spamScore'], 0.5);
+    $this->assertSame('spamClassification', $result['spamClassification'], 'unsure');
+    $this->assertSame('qualityScore', $result['qualityScore'], 0.0);
+    $this->assertSame('profanityScore', $result['profanityScore'], 1.0);
+    $data['id'] = $this->assertResponseID('contentId', $result['id']);
 
     // Disable spam checking, only do profanity checking.
-    $data['post_body'] = 'spam profanity';
-    $data['checks'] = 'profanity';
-    $data['session_id'] = $session_id;
-    $result = mollom('mollom.checkContent', $data);
+    $data['postBody'] = 'spam profanity';
+    $data['checks'] = array('profanity');
+    $result = $mollom->checkContent($data);
     $this->assertMollomWatchdogMessages();
-    $this->assertTrue(!isset($result['spam']), 'spam not returned.');
-    $this->assertTrue(!isset($result['quality']), 'quality not returned.');
-    $this->assertSame('profanity', $result['profanity'], 1);
-    $session_id = $this->assertSessionID($result['session_id']);
+    $this->assertTrue(!isset($result['spamScore']), 'spam not returned.');
+    $this->assertTrue(!isset($result['spamClassification']), 'spamClassification not returned.');
+    $this->assertTrue(!isset($result['qualityScore']), 'qualityScore not returned.');
+    $this->assertSame('profanityScore', $result['profanityScore'], 1.0);
+    $data['id'] = $this->assertResponseID('contentId', $result['id']);
 
     // Pass arbitrary string to profanity checking.
-    $data['post_body'] = $this->randomString(12);
-    $data['session_id'] = $session_id;
-    $result = mollom('mollom.checkContent', $data);
+    $data['postBody'] = $this->randomString(12);
+    $result = $mollom->checkContent($data);
     $this->assertMollomWatchdogMessages();
-    $this->assertTrue(!isset($result['spam']), 'spam not returned.');
-    $this->assertTrue(!isset($result['quality']), 'quality not returned.');
-    $this->assertSame('profanity', $result['profanity'], 0);
-    $session_id = $this->assertSessionID($result['session_id']);
+    $this->assertTrue(!isset($result['spamScore']), 'spam not returned.');
+    $this->assertTrue(!isset($result['spamClassification']), 'spamClassification not returned.');
+    $this->assertTrue(!isset($result['qualityScore']), 'qualityScore not returned.');
+    $this->assertSame('profanityScore', $result['profanityScore'], 0.0);
+    $data['id'] = $this->assertResponseID('contentId', $result['id']);
   }
 
   /**
    * Tests results of mollom.checkContent() across requests for a single session.
    */
   function testCheckContentSession() {
+    $mollom = mollom();
     $data = array(
-      'author_name' => $this->admin_user->name,
-      'author_mail' => $this->admin_user->mail,
-      'author_id' => $this->admin_user->uid,
-      'author_ip' => ip_address(),
+      'authorName' => $this->admin_user->name,
+      'authorMail' => $this->admin_user->mail,
+      'authorId' => $this->admin_user->uid,
+      'authorIp' => ip_address(),
     );
 
     // Sequence: Post unsure spam, correct CAPTCHA, change post into spam,
     // expect it to be ham (due to correct CAPTCHA).
-    $data['post_body'] = 'unsure';
-    $result = mollom('mollom.checkContent', $data);
+    $data['postBody'] = 'unsure';
+    $result = $mollom->checkContent($data);
     $this->assertMollomWatchdogMessages();
-    $this->assertSame('spam', $result['spam'], MOLLOM_ANALYSIS_UNSURE);
-    $data['session_id'] = $this->assertSessionID($result['session_id']);
+    $this->assertSame('spamScore', $result['spamScore'], 0.5);
+    $this->assertSame('spamClassification', $result['spamClassification'], 'unsure');
+    $data['id'] = $contentId = $this->assertResponseID('contentId', $result['id']);
 
     $captcha_data = array(
-      'session_id' => $data['session_id'],
-      'author_ip' => $data['author_ip'],
+      'type' => 'image',
+      'contentId' => $contentId,
+      'authorIp' => $data['authorIp'],
     );
-    $result = mollom('mollom.getImageCaptcha', $captcha_data);
+    $result = $mollom->getCaptcha($captcha_data);
     $this->assertMollomWatchdogMessages();
-    $data['session_id'] = $this->assertSessionID($result['session_id']);
+    $data['captchaId'] = $captchaId = $this->assertResponseID('captchaId', $result['id']);
 
     $captcha_data = array(
-      'session_id' => $data['session_id'],
-      'author_ip' => $data['author_ip'],
-      'author_id' => $data['author_id'],
-      'captcha_result' => 'correct',
+      'id' => $captchaId,
+      'contentId' => $contentId,
+      'authorIp' => $data['authorIp'],
+      'authorId' => $data['authorId'],
+      'solution' => 'correct',
     );
-    $result = mollom('mollom.checkCaptcha', $captcha_data);
+    $result = $mollom->checkCaptcha($captcha_data);
     $this->assertMollomWatchdogMessages();
-    $this->assertIdentical($result, TRUE, t('CAPTCHA response was correct.'));
+    $this->assertSame('solved', $result['solved'], 1);
 
-    $data['post_body'] = 'spam';
-    $result = mollom('mollom.checkContent', $data);
+    $data['postBody'] = 'spam';
+    $result = $mollom->checkContent($data);
     $this->assertMollomWatchdogMessages();
-    $this->assertSame('spam', $result['spam'], MOLLOM_ANALYSIS_HAM);
-    $data['session_id'] = $this->assertSessionID($result['session_id']);
+    $this->assertSame('spamScore', $result['spamScore'], 0.0);
+    $this->assertSame('spamClassification', $result['spamClassification'], 'ham');
+    $data['contentId'] = $this->assertResponseID('contentId', $result['id']);
+  }
+
+  /**
+   * Tests the language detection functionality at the API level.
+   */
+  function testCheckContentLanguage() {
+    // Note that Mollom supports more languages than those tested.
+    $tests = array(
+      'Some text in lang-en.' => array(
+        'en' => 1.0,
+      ),
+      'Etwas lang-de and some lang-en, too.' => array(
+        'en' => 0.5,
+        'de' => 0.5,
+      ),
+      'Unknown language' => array(
+        'zxx' => 1.0,
+      ),
+      'lang-en lang-de lang-es lang-ru lang-it' => array(
+        LANGUAGE_NONE => 1.0,
+      ),
+    );
+
+    $mollom = mollom();
+    foreach ($tests as $string => $expected) {
+      $result = $mollom->checkContent(array(
+        'checks' => 'language',
+        'postBody' => $string,
+      ));
+      // Parse result values.
+      $actual = array();
+      foreach ($result['languages'] as $language) {
+        $actual[$language['languageCode']] = $language['confidence'];
+      }
+      $this->assertSame($string, $actual, $expected);
+    }
   }
 
   /**
    * Tests mollom.getImageCaptcha().
    */
   function testGetImageCaptcha() {
+    $mollom = mollom();
     // Ensure we get no SSL URL by default.
     $data = array(
-      'author_ip' => ip_address(),
+      'type' => 'image',
+      'authorIp' => ip_address(),
     );
-    $result = mollom('mollom.getImageCaptcha', $data);
+    $result = $mollom->getCaptcha($data);
     $this->assertMollomWatchdogMessages();
     $this->assertTrue(strpos($result['url'], 'http://') === 0, t('CAPTCHA URL uses HTTP protocol.'));
 
     // Ensure we get a SSL URL when passing the 'ssl' parameter.
-    $data = array(
-      'author_ip' => ip_address(),
-      'ssl' => TRUE,
-    );
-    $result = mollom('mollom.getImageCaptcha', $data);
+    $data['ssl'] = TRUE;
+    $result = $mollom->getCaptcha($data);
     $this->assertMollomWatchdogMessages();
     $this->assertTrue(strpos($result['url'], 'https://') === 0, t('CAPTCHA URL uses HTTPS protocol.'));
   }
@@ -1036,22 +1276,24 @@ class MollomResponseTestCase extends MollomWebTestCase {
    * Tests mollom.checkCaptcha().
    */
   function testCheckCaptcha() {
+    $mollom = mollom();
     // Ensure we can send an 'author_id'.
     // Verifying no severe watchdog messages is sufficient, as unsupported
     // parameters would trigger a XML-RPC error.
     $uid = rand();
     $data = array(
-      'author_ip' => ip_address(),
-      'author_id' => $uid,
+      'type' => 'image',
+      'authorIp' => ip_address(),
+      'authorId' => $uid,
     );
-    $result = mollom('mollom.getImageCaptcha', $data);
+    $result = $mollom->getCaptcha($data);
     $this->assertMollomWatchdogMessages();
+    $data['id'] = $this->assertResponseID('captchaId', $result['id']);
 
     $data += array(
-      'session_id' => $result['session_id'],
-      'captcha_result' => 'correct',
+      'solution' => 'correct',
     );
-    $result = mollom('mollom.checkCaptcha', $data);
+    $result = $mollom->checkCaptcha($data);
     $this->assertMollomWatchdogMessages();
   }
 }
@@ -1060,6 +1302,9 @@ class MollomResponseTestCase extends MollomWebTestCase {
  * Tests low-level communication with local fake Mollom server.
  */
 class MollomResponseLocalTestCase extends MollomResponseTestCase {
+  // Re-route Mollom communication to this testing site.
+  protected $mollomClass = 'MollomDrupalTestLocal';
+
   public static function getInfo() {
     return array(
       'name' => 'Server responses (local)',
@@ -1067,13 +1312,6 @@ class MollomResponseLocalTestCase extends MollomResponseTestCase {
       'group' => 'Mollom',
     );
   }
-
-  function setUp() {
-    // Enable testing server implementation.
-    parent::setUp(array('mollom_test'));
-    // Re-route Mollom communication to this testing site.
-    variable_set('mollom_servers', array($GLOBALS['base_url'] . '/xmlrpc.php?version='));
-  }
 }
 
 class MollomAccessTestCase extends MollomWebTestCase {
@@ -1155,6 +1393,7 @@ class MollomAccessTestCase extends MollomWebTestCase {
     $this->web_user = $this->drupalCreateUser(array('edit own comments'));
     $this->drupalLogin($this->web_user);
     $edit = array(
+      'subject' => 'ham',
       'comment_body[und][0][value]' => 'ham',
     );
     $this->drupalPost('comment/reply/' . $node->nid, $edit, t('Preview'));
@@ -1170,6 +1409,7 @@ class MollomAccessTestCase extends MollomWebTestCase {
     $this->clickLink('edit');
 
     $edit = array(
+      'subject' => 'spam',
       'comment_body[und][0][value]' => 'spam',
     );
     $this->drupalPost(NULL, $edit, t('Preview'));
@@ -1178,6 +1418,7 @@ class MollomAccessTestCase extends MollomWebTestCase {
     $this->drupalPost(NULL, array(), t('Save'));
     $this->assertNoText($this->spam_message);
     $this->assertText('node body');
+    $this->assertText($edit['comment_body[und][0][value]']);
 
     // Log in back the regular user and try to edit the comment containing spam.
     $this->drupalLogin($this->web_user);
@@ -1194,6 +1435,9 @@ class MollomAccessTestCase extends MollomWebTestCase {
 }
 
 class MollomFallbackTestCase extends MollomWebTestCase {
+  // Re-route Mollom communication to this testing site.
+  protected $mollomClass = 'MollomDrupalTestLocal';
+
   public static function getInfo() {
     return array(
       'name' => 'Fallback behavior',
@@ -1202,11 +1446,6 @@ class MollomFallbackTestCase extends MollomWebTestCase {
     );
   }
 
-  function setUp() {
-    // Enable testing server implementation.
-    parent::setUp(array('mollom_test'));
-  }
-
   /**
    * Make sure that "request new password" submissions can be blocked when
    * the Mollom servers are unreachable.
@@ -1274,8 +1513,7 @@ class MollomFallbackTestCase extends MollomWebTestCase {
     variable_set('mollom_servers', array(
       'http://fake-host-1',
       'http://fake-host-2',
-      $GLOBALS['base_url'] . '/xmlrpc.php?version=',
-      'http://xmlrpc1.mollom.com', // The real server.
+      $GLOBALS['base_url'] . '/mollom-test/rest/' . Mollom::API_VERSION, // A real server.
       'http://fake-host-3',
     ));
 
@@ -1291,6 +1529,8 @@ class MollomFallbackTestCase extends MollomWebTestCase {
 }
 
 class MollomServerListRecoveryTestCase extends MollomWebTestCase {
+  protected $profile = 'testing';
+
   public static function getInfo() {
     return array(
       'name' => 'Server list recovery',
@@ -1316,54 +1556,22 @@ class MollomServerListRecoveryTestCase extends MollomWebTestCase {
     );
 
     foreach ($list as $servers) {
-      // Call mollom.verifyKey with an invalid server list.  The expected behavior
-      // is that the first call fails, but that the second call succeeds because
-      // the server list is automatically reset or recovered by the Mollom module.
       variable_set('mollom_servers', $servers);
+      // Reset static, since the Mollom instance is statically cached.
+      drupal_static_reset('mollom');
 
-      $key_is_valid = mollom('mollom.verifyKey');
-      $this->assertIdentical($key_is_valid, NETWORK_ERROR, t('The Mollom servers could not be contacted.'));
+      // Verify that Mollom::query() fails with an invalid server list and
+      // appropriate messages are logged.
+      $mollom = mollom();
+      $key_is_valid = $mollom->verifyKey();
       $this->assertMollomWatchdogMessages(WATCHDOG_EMERGENCY);
+      $this->assertIdentical($key_is_valid, Mollom::NETWORK_ERROR, t('The Mollom servers could not be contacted.'));
 
-      $key_is_valid = mollom('mollom.verifyKey');
-      $this->assertIdentical($key_is_valid, TRUE, t('The Mollom servers could be contacted.'));
+      // Verify that Mollom::query() automatically empties the server list and
+      // a directly following, subsequent request attempt succeeds.
+      $key_is_valid = $mollom->verifyKey();
       $this->assertMollomWatchdogMessages();
-    }
-  }
-}
-
-class MollomLanguageDetectionTestCase extends MollomWebTestCase {
-  public static function getInfo() {
-    return array(
-      'name' => 'Language detection',
-      'description' => 'Tests language detection functionality.',
-      'group' => 'Mollom',
-    );
-  }
-
-  /**
-   * Test the language detection functionality at the API level without using a web interface.
-   */
-  function testLanguageDetectionAPI() {
-    // Note that Mollom supports more languages than those tested.
-    $strings = array(
-      'en' => "Hi, this is a test of the language detection code to see if it works well.",
-      'nl' => "Hallo, dit is een test van de taaldetectiecode om te controleren of het werkt.",
-      'fr' => "Bonjour, ceci est un test du detecteur langue automatique pour voir ci ça marche bien.",
-      'de' => "Bedecke deinen Himmel, Zeus, Mit Wolkendunst Und übe, dem Knaben gleich, der Disteln köpft, An Eichen dich und Bergeshöhn.",
-      'ko' => "'엄마야 누나야 강변살자. 뜰에는 반짝이는 금모래 빛. 뒷문 밖에는 갈잎의 노래",
-      'ru' => "Холуй трясется. Раб хохочет. Палач свою секиру точит. Тиран кромсает каплуна. Сверкает зимняя луна.",
-      'hu' => "Földszintiek mászófámról pillantva fejjel lefelé ti lógtok bele nézőim az űrbe ki tudja így kölcsönös kíváncsiak a helyes felelet kié",
-      'el' => "Σαν να 'χουνε την όψη της αιώνες οργωμένη. Κάτι άναρχο κι ατέλειωτο στο πρόσωπό της μένει.",
-      'ja' => "吹くからに秋の草木のしをるれば",
-      'th' => "ทั่วประเทศ ประมาณ ๔๐,๐๐๐ แห่ง ชาวไทยนับตั้งแต่ครั้งอดีตมีวิถี ชีวิตผูกพันกับพุทธศาสนาอย่างใกล้ชิด แสดงออกมาเป็น ขนบธรรมเนียมประเพณี",
-      'zh' => "螽斯羽，诜诜兮。宜尔子孙，振振兮",
-    );
-
-    foreach ($strings as $language => $text) {
-      $result = mollom('mollom.detectLanguage', array('text' => $text));
-      $this->assertEqual($result[0]['language'], $language, t('A language code was specified and they match.'));
-      $this->assertTrue($result[0]['confidence'] > 0, t('A confidence value was specified and it is greater than 0.'));
+      $this->assertIdentical($key_is_valid, TRUE, t('The Mollom servers could be contacted.'));
     }
   }
 }
@@ -1393,141 +1601,137 @@ class MollomBlacklistTestCase extends MollomWebTestCase {
   }
 
   /**
-   * Test the URL blacklist functionality at the API level without using a web interface.
+   * Test the blacklist functionality at the API level without using a web interface.
    */
-  function testUrlBlacklistAPI() {
+  function testBlacklistAPI() {
+    $mollom = mollom();
     // Remove any stale blacklist entries from test runs that did not finish.
-    $blacklist = mollom('mollom.listBlacklistURL');
+    $blacklist = $mollom->getBlacklist();
     foreach ($blacklist as $entry) {
       if (REQUEST_TIME - strtotime($entry['created']) > 86400) {
-        mollom('mollom.removeBlacklistURL', array('url' => $entry['url']));
+        $mollom->deleteBlacklistEntry($entry['id']);
       }
     }
+    $this->assertMollomWatchdogMessages();
 
     // Blacklist a URL.
-    $domain = $this->randomName() . '.com';
-    $result = mollom('mollom.addBlacklistURL', array('url' => 'http://' . $domain));
-    $this->assertTrue($result, t('The URL was blacklisted.'));
-
-    // Check whether posts containing the blacklisted URL are properly blocked.
-    $result = mollom('mollom.checkContent', array(
-      'post_body' => "When the exact URL is present, the post should get blocked: http://{$domain}",
+    $domain = drupal_strtolower($this->randomName()) . '.com';
+    $entry = $mollom->createBlacklistEntry(array(
+      'value' => $domain,
+      'context' => 'allFields',
+      'reason' => 'spam',
+      'match' => 'contains',
     ));
-    $this->assertEqual($result['spam'], MOLLOM_ANALYSIS_SPAM, t('Exact URL match was blocked.'));
+    $this->assertMollomWatchdogMessages();
+    $this->assertTrue($entry['id'], t('The URL was blacklisted.'));
 
-    $result = mollom('mollom.checkContent', array(
-      'post_body' => "When the URL is expanded in the back, the post should get blocked: http://{$domain}/oh-my",
+    // Check whether posts containing the blacklisted URL are properly blocked.
+    $result = mollom()->checkContent(array(
+      'postBody' => "When the exact URL is present, the post should get blocked: http://{$domain}",
     ));
-    $this->assertEqual($result['spam'], MOLLOM_ANALYSIS_SPAM, t('Partial URL match was blocked.'));
+    $this->assertMollomWatchdogMessages();
+    $this->assertSame('spamScore', $result['spamScore'], 1.0);
+    $this->assertEqual($result['spamClassification'], 'spam', t('Exact URL match was blocked.'));
 
-    $result = mollom('mollom.checkContent', array(
-      'post_body' => "When the URL is expanded in the front, the post should get blocked: http://www.{$domain}",
+    $result = mollom()->checkContent(array(
+      'postBody' => "When the URL is expanded in the back, the post should get blocked: http://{$domain}/oh-my",
     ));
-    $this->assertEqual($result['spam'], MOLLOM_ANALYSIS_SPAM, t('URL with www-prefix was blocked.'));
+    $this->assertMollomWatchdogMessages();
+    $this->assertSame('spamScore', $result['spamScore'], 1.0);
+    $this->assertEqual($result['spamClassification'], 'spam', t('Partial URL match was blocked.'));
 
-    $result = mollom('mollom.checkContent', array(
-      'post_body' => "When the URL has a different schema, the post should get blocked: ftp://www.{$domain}",
+    $result = mollom()->checkContent(array(
+      'postBody' => "When the URL is expanded in the front, the post should get blocked: http://www.{$domain}",
     ));
-    $this->assertEqual($result['spam'], MOLLOM_ANALYSIS_SPAM, t('URL with different schema was blocked.'));
+    $this->assertMollomWatchdogMessages();
+    $this->assertSame('spamScore', $result['spamScore'], 1.0);
+    $this->assertEqual($result['spamClassification'], 'spam', t('URL with www-prefix was blocked.'));
 
-    // @todo Not implemented yet.
-    /*
-    $result = mollom('mollom.checkContent', array(
-      'post_body' => "When the domain appears on its own, the post should get blocked: www.{$domain}",
+    $result = mollom()->checkContent(array(
+      'postBody' => "When the URL has a different schema, the post should get blocked: ftp://www.{$domain}",
     ));
-    $this->assertEqual($result['spam'], MOLLOM_ANALYSIS_SPAM, t('Plain domain name with www-prefix was blocked.'));
-    */
-
-    $result = mollom('mollom.removeBlacklistURL', array('url' => 'http://' . $domain));
-    $this->assertTrue($result, t('The blacklisted URL was removed.'));
-  }
+    $this->assertMollomWatchdogMessages();
+    $this->assertSame('spamScore', $result['spamScore'], 1.0);
+    $this->assertEqual($result['spamClassification'], 'spam', t('URL with different schema was blocked.'));
 
-  /**
-   * Test the text blacklist functionality at the API level without using a web interface.
-   */
-  function testTextBlacklistAPI() {
-    // Remove any stale blacklist entries from test runs that did not finish.
-    $blacklist = mollom('mollom.listBlacklistText');
-    foreach ($blacklist as $entry) {
-      if (REQUEST_TIME - strtotime($entry['created']) > 86400) {
-        mollom('mollom.removeBlacklistText', array(
-          'text' => $entry['text'],
-          'context' => $entry['context'],
-          'reason' => $entry['reason'],
-        ));
-      }
-    }
+    $result = $mollom->deleteBlacklistEntry($entry['id']);
+    $this->assertMollomWatchdogMessages();
+    $this->assertIdentical($result, TRUE, t('The blacklisted URL was removed.'));
 
     // Blacklist a word.
     // @todo As of now, only non-numeric, lower-case text seems to be supported.
     $term = drupal_strtolower(preg_replace('/[^a-zA-Z]/', '', $this->randomName()));
-    $data = array(
-      'text' => $term,
+    $entry = $mollom->createBlacklistEntry(array(
+      'value' => $term,
       'context' => 'everything',
       'reason' => 'spam',
       'match' => 'contains',
-    );
-    $result = mollom('mollom.addBlacklistText', $data);
-    $this->assertIdentical($result, TRUE, t('The text was blacklisted.'));
+    ));
+    $this->assertMollomWatchdogMessages();
+    $this->assertTrue($entry['id'], t('The text was blacklisted.'));
 
     // Check whether posts containing the blacklisted word are properly blocked.
     $data = array(
-      'post_body' => $term,
+      'postBody' => $term,
     );
-    $result = mollom('mollom.checkContent', $data);
-    $this->assertEqual($result['spam'], MOLLOM_ANALYSIS_SPAM, t('Identical match was blocked.'));
+    $result = mollom()->checkContent($data);
+    $this->assertMollomWatchdogMessages();
+    $this->assertSame('spamScore', $result['spamScore'], 1.0);
+    $this->assertEqual($result['spamClassification'], 'spam', t('Identical match was blocked.'));
 
     $data = array(
-      'post_body' => "When the term is present, the post should get blocked: " . $term,
+      'postBody' => "When the term is present, the post should get blocked: " . $term,
     );
-    $result = mollom('mollom.checkContent', $data);
-    $this->assertEqual($result['spam'], MOLLOM_ANALYSIS_SPAM, t('Exact match was blocked.'));
+    $result = mollom()->checkContent($data);
+    $this->assertMollomWatchdogMessages();
+    $this->assertSame('spamScore', $result['spamScore'], 1.0);
+    $this->assertEqual($result['spamClassification'], 'spam', t('Exact match was blocked.'));
 
     $data = array(
-      'post_body' => "When match is 'contains', the word can be surrounded by other text: abc" . $term . "def",
+      'postBody' => "When match is 'contains', the word can be surrounded by other text: abc" . $term . "def",
     );
-    $result = mollom('mollom.checkContent', $data);
-    $this->assertEqual($result['spam'], MOLLOM_ANALYSIS_SPAM, t('Partial match was blocked.'));
+    $result = mollom()->checkContent($data);
+    $this->assertMollomWatchdogMessages();
+    $this->assertSame('spamScore', $result['spamScore'], 1.0);
+    $this->assertEqual($result['spamClassification'], 'spam', t('Partial match was blocked.'));
 
     // Update the blacklist entry to match the term only exactly.
-    $data = array(
-      'text' => $term,
+    $entry = $mollom->updateBlacklistEntry(array(
+      'id' => $entry['id'],
+      'value' => $term,
       'context' => 'everything',
       'reason' => 'spam',
       'match' => 'exact',
-    );
-    $result = mollom('mollom.addBlacklistText', $data);
-    $this->assertTrue($result, t('The text was blacklisted.'));
+    ));
+    $this->assertMollomWatchdogMessages();
+    $this->assertTrue($entry['id'], t('The blacklist entry was updated.'));
 
     $data = array(
-      'post_body' => "When match is 'exact', it has to be exact: " . $term,
+      'postBody' => "When match is 'exact', it has to be exact: " . $term,
     );
-    $result = mollom('mollom.checkContent', $data);
-    $this->assertEqual($result['spam'], MOLLOM_ANALYSIS_SPAM, t('Exact match was blocked.'));
+    $result = mollom()->checkContent($data);
+    $this->assertMollomWatchdogMessages();
+    $this->assertSame('spamScore', $result['spamScore'], 1.0);
+    $this->assertEqual($result['spamClassification'], 'spam', t('Exact match was blocked.'));
 
     $data = array(
-      'post_body' => "When match is 'exact', it has to be exact: abc{$term}def",
+      'postBody' => "When match is 'exact', it has to be exact: abc{$term}def",
     );
-    $result = mollom('mollom.checkContent', $data);
-    $this->assertNotEqual($result['spam'], MOLLOM_ANALYSIS_SPAM, t('Partial match was not blocked.'));
+    $result = mollom()->checkContent($data);
+    $this->assertMollomWatchdogMessages();
+    $this->assertSame('spamScore', $result['spamScore'], 0.5);
+    $this->assertEqual($result['spamClassification'], 'unsure', t('Partial match was not blocked.'));
 
-    $data = array(
-      'text' => $term,
-      'context' => 'everything',
-      'reason' => 'spam',
-    );
-    $result = mollom('mollom.removeBlacklistText', $data);
-    $this->assertTrue($result, t('The blacklisted text was removed.'));
+    $result = $mollom->deleteBlacklistEntry($entry['id']);
+    $this->assertMollomWatchdogMessages();
+    $this->assertIdentical($result, TRUE, t('The blacklisted text was removed.'));
 
     // Try to remove a non-existing entry.
-    $data = array(
-      'text' => $term,
-      'context' => 'everything',
-      'reason' => 'spam',
-    );
-    $result = mollom('mollom.removeBlacklistText', $data);
+    // @todo Ensure that the ID does not exist.
+    $result = $mollom->deleteBlacklistEntry(999);
     $this->assertMollomWatchdogMessages(WATCHDOG_EMERGENCY);
     $this->assertNotIdentical($result, TRUE, t('Error response for a non-existing blacklist text found.'));
+    $this->assertSame('Response code', $mollom->lastResponseCode, 404);
   }
 
   /**
@@ -1549,7 +1753,7 @@ class MollomBlacklistTestCase extends MollomWebTestCase {
     $this->drupalGet('admin/config/content/mollom/blacklist');
     $text = $this->randomName();
     $edit = array(
-      'entry[text]' => $text,
+      'entry[value]' => $text,
       'entry[context]' => 'everything',
       'entry[match]' => 'contains',
     );
@@ -1572,7 +1776,7 @@ class MollomBlacklistTestCase extends MollomWebTestCase {
     $this->drupalGet('admin/config/content/mollom/blacklist/profanity');
     $text = $this->randomName();
     $edit = array(
-      'entry[text]' => $text,
+      'entry[value]' => $text,
       'entry[context]' => 'everything',
       'entry[match]' => 'contains',
     );
@@ -1685,7 +1889,7 @@ class MollomProfanityTestCase extends MollomWebTestCase {
     $this->drupalPost(NULL, $edit, t('Save'));
     $this->assertText($this->profanity_message);
     $this->assertNoText(t('Your comment has been posted.'));
-    $session_id = $this->assertSessionIDInForm();
+    $contentId = $this->assertResponseIDInForm('contentId');
 
     $edit["comment_body[$langcode][0][value]"] = 'not profane ham';
     $this->drupalPost(NULL, $edit, t('Save'));
@@ -1693,10 +1897,11 @@ class MollomProfanityTestCase extends MollomWebTestCase {
     $this->assertText(t('Your comment has been posted.'));
     $this->assertRaw('<p>' . $edit["comment_body[$langcode][0][value]"] . '</p>', t('Comment previously containing profanity was found.'));
     $cid = db_query('SELECT cid FROM {comment} WHERE subject = :subject ORDER BY created DESC', array(':subject' => $edit['subject']))->fetchField();
-    $this->assertMollomData('comment', $cid, $session_id);
+    $this->assertMollomData('comment', $cid, 'contentId', $contentId);
 
     // Sequence: Post unsure spam (not profanity), post profanity along with
     // correct CAPTCHA, and expect that to be discarded.
+    $this->resetResponseID();
     $this->web_user = $this->drupalCreateUser();
     $this->drupalLogin($this->web_user);
     $edit = array(
@@ -1711,7 +1916,8 @@ class MollomProfanityTestCase extends MollomWebTestCase {
     $this->assertCaptchaField();
     $this->assertNoText($this->profanity_message);
     $this->assertNoText(t('Your comment has been posted.'));
-    $session_id = $this->assertSessionIDInForm();
+    $contentId = $this->assertResponseIDInForm('contentId');
+    $captchaId = $this->assertResponseIDInForm('captchaId');
 
     $edit["comment_body[$langcode][0][value]"] = 'unsure profanity';
     $this->postCorrectCaptcha(NULL, $edit, t('Save'));
@@ -1725,6 +1931,9 @@ class MollomProfanityTestCase extends MollomWebTestCase {
  * Tests Mollom form configuration functionality.
  */
 class MollomFormConfigurationTestCase extends MollomWebTestCase {
+  // Re-route Mollom communication to this testing site.
+  protected $mollomClass = 'MollomDrupalTestLocal';
+
   public static function getInfo() {
     return array(
       'name' => 'Form administration',
@@ -1734,9 +1943,7 @@ class MollomFormConfigurationTestCase extends MollomWebTestCase {
   }
 
   function setUp() {
-    parent::setUp(array('mollom_test'));
-    // Re-route Mollom communication to this testing site.
-    variable_set('mollom_servers', array($GLOBALS['base_url'] . '/xmlrpc.php?version='));
+    parent::setUp();
 
     $this->drupalLogin($this->admin_user);
   }
@@ -2191,7 +2398,7 @@ class MollomNodeFormTestCase extends MollomWebTestCase {
     // Login and submit a node.
     $this->drupalLogin($this->web_user);
     $this->drupalGet('node/add/article');
-    $session_id = $this->assertSessionIDInForm();
+    $captchaId = $this->assertResponseIDInForm('captchaId');
     $edit = array(
       'title' => 'spam',
       'mollom[captcha]' => 'correct',
@@ -2199,7 +2406,7 @@ class MollomNodeFormTestCase extends MollomWebTestCase {
     $this->drupalPost(NULL, $edit, t('Save'));
     $this->node = $this->drupalGetNodeByTitle($edit['title']);
     $this->assertUrl('node/' . $this->node->nid);
-    $this->assertMollomData('node', $this->node->nid, $session_id);
+    $this->assertMollomData('node', $this->node->nid, 'captchaId', $captchaId);
   }
 
   /**
@@ -2280,21 +2487,21 @@ class MollomCommentFormTestCase extends MollomWebTestCase {
     $this->drupalLogin($this->web_user);
     $this->drupalGet('comment/reply/' . $this->node->nid);
     $this->assertCaptchaField();
-    $this->assertSessionIDInForm();
+    $this->assertResponseIDInForm('captchaId');
     $this->assertNoPrivacyLink();
 
     // Try to submit an incorrect answer for the CAPTCHA, without value for
     // required field.
     $this->postIncorrectCaptcha(NULL, array(), t('Preview'));
     $this->assertText(t('Comment field is required.'));
-    $this->assertSessionIDInForm();
+    $this->assertResponseIDInForm('captchaId');
     $this->assertNoPrivacyLink();
 
     // Try to submit a correct answer for the CAPTCHA, still without required
     // field value.
     $this->postCorrectCaptcha(NULL, array(), t('Preview'));
     $this->assertText(t('Comment field is required.'));
-    $session_id = $this->assertSessionIDInForm();
+    $captchaId = $this->assertResponseIDInForm('captchaId');
     $this->assertNoPrivacyLink();
 
     // Finally, we should be able to submit a comment.
@@ -2302,13 +2509,14 @@ class MollomCommentFormTestCase extends MollomWebTestCase {
     $this->assertText(t('Your comment has been posted.'));
     $this->assertRaw('<p>spam</p>', t('Spam comment could be posted with correct CAPTCHA.'));
     $cid = db_query('SELECT cid FROM {comment} WHERE subject = :subject ORDER BY created DESC', array(':subject' => 'spam'))->fetchField();
-    $this->assertMollomData('comment', $cid, $session_id);
+    $this->assertMollomData('comment', $cid, 'captchaId', $captchaId);
 
     // Verify we can solve the CAPTCHA directly.
+    $this->resetResponseID();
     $value = 'some more spam';
     $this->drupalGet('comment/reply/' . $this->node->nid);
     $this->assertCaptchaField();
-    $session_id = $this->assertSessionIDInForm();
+    $captchaId = $this->assertResponseIDInForm('captchaId');
     $edit = array(
       'comment_body[und][0][value]' => $value,
       'mollom[captcha]' => 'correct',
@@ -2316,7 +2524,7 @@ class MollomCommentFormTestCase extends MollomWebTestCase {
     $this->drupalPost(NULL, $edit, t('Save'));
     $this->assertText(t('Your comment has been posted.'));
     $cid = db_query('SELECT cid FROM {comment} WHERE subject = :subject ORDER BY created DESC', array(':subject' => $value))->fetchField();
-    $this->assertMollomData('comment', $cid, $session_id);
+    $this->assertMollomData('comment', $cid, 'captchaId', $captchaId);
   }
 
   /**
@@ -2340,7 +2548,7 @@ class MollomCommentFormTestCase extends MollomWebTestCase {
     );
     $this->drupalPost(NULL, $edit, t('Save'));
     $this->assertCaptchaField();
-    $session_id = $this->assertSessionIDInForm();
+    $contentId = $this->assertResponseIDInForm('contentId');
     $this->assertPrivacyLink();
 
     // Try to submit the form by solving the CAPTCHA incorrectly. At this point,
@@ -2348,34 +2556,34 @@ class MollomCommentFormTestCase extends MollomWebTestCase {
     // the comment is still neither ham or spam.
     $this->postIncorrectCaptcha(NULL, array(), t('Save'));
     $this->assertCaptchaField();
-    $session_id = $this->assertSessionIDInForm();
+    $captchaId = $this->assertResponseIDInForm('captchaId');
     $this->assertPrivacyLink();
 
     // Correctly solving the CAPTCHA should accept the form submission.
     $this->postCorrectCaptcha(NULL, array(), t('Save'));
     $this->assertRaw('<p>' . $edit['comment_body[und][0][value]'] . '</p>', t('A comment that may contain spam was found.'));
     $cid = db_query('SELECT cid FROM {comment} WHERE subject = :subject ORDER BY created DESC', array(':subject' => $edit['comment_body[und][0][value]']))->fetchField();
-    $this->assertMollomData('comment', $cid, $session_id);
+    $this->assertMollomData('comment', $cid, 'contentId', $contentId);
 
     // Try to save a new 'spam' comment; it should be discarded, with no CAPTCHA
     // appearing on the page.
-    $this->resetSessionID();
+    $this->resetResponseID();
     $this->drupalGet('comment/reply/' . $this->node->nid);
     $this->assertPrivacyLink();
     $original_number_of_comments = $this->getCommentCount($this->node->nid);
     $this->assertSpamSubmit(NULL, array('comment_body[und][0][value]'), array(), t('Save'));
-    $session_id = $this->assertSessionIDInForm();
+    $contentId = $this->assertResponseIDInForm('contentId');
     $this->assertCommentCount($this->node->nid, $original_number_of_comments);
     $this->assertPrivacyLink();
 
     // Try to save again; it should be discarded, with no CAPTCHA.
     $this->assertSpamSubmit(NULL, array('comment_body[und][0][value]'), array(), t('Save'));
-    $session_id = $this->assertSessionIDInForm();
+    $contentId = $this->assertResponseIDInForm('contentId');
     $this->assertCommentCount($this->node->nid, $original_number_of_comments);
     $this->assertPrivacyLink();
 
     // Save a new 'ham' comment.
-    $this->resetSessionID();
+    $this->resetResponseID();
     $this->drupalGet('comment/reply/' . $this->node->nid);
     $this->assertPrivacyLink();
     $original_number_of_comments = $this->getCommentCount($this->node->nid);
@@ -2503,16 +2711,16 @@ class MollomContactFormTestCase extends MollomWebTestCase {
     $this->assertText($success);
     $report_link = $this->parseMollomMailReportLink();
     $this->assertTrue($report_link, t('Report to Mollom link found in e-mail.'));
-    $this->assertEqual($report_link['entity'], 'session', t('Report link in e-mail uses entity type "session".'));
-    $this->assertMollomData($report_link['entity'], $report_link['session_id']);
+    $this->assertEqual($report_link['entity'], 'mollom_content', t('Report link in e-mail uses entity type "session".'));
+    $this->assertMollomData($report_link['entity'], $report_link['id']);
 
     // Submit an 'unsure' message.  This should be accepted only after the
     // CAPTCHA has been solved.
     $this->assertUnsureSubmit($url, array('subject', 'message'), array(), $button, $success);
     $report_link = $this->parseMollomMailReportLink();
     $this->assertTrue($report_link, t('Report to Mollom link found in e-mail.'));
-    $this->assertEqual($report_link['entity'], 'session', t('Report link in e-mail uses entity type "session".'));
-    $this->assertMollomData($report_link['entity'], $report_link['session_id']);
+    $this->assertEqual($report_link['entity'], 'mollom_content', t('Report link in e-mail uses entity type "session".'));
+    $this->assertMollomData($report_link['entity'], $report_link['id']);
 
     // Report the mail to Mollom.
     $this->drupalGet($report_link['url']);
@@ -2541,7 +2749,7 @@ class MollomContactFormTestCase extends MollomWebTestCase {
       $found = array(
         'url' => $matches[0],
         'entity' => $matches[1],
-        'session_id' => $matches[2],
+        'id' => $matches[2],
         'mail' => $email,
       );
     }
@@ -2569,7 +2777,7 @@ class MollomResellerTestCase extends MollomWebTestCase {
 
     // Create 3 test sites:
     for ($i = 1; $i <= 3; $i++) {
-      $keys[] = mollom('mollom.createSite', array(
+      $keys[] = mollom()->createSite(array(
         'url' => 'http://example.com/site-'. $i,
         'mail' => 'mail@example.com',
         'status' => 0,
@@ -2583,7 +2791,7 @@ class MollomResellerTestCase extends MollomWebTestCase {
     $sites = mollom('mollom.listSites');
     foreach ($sites as $site) {
       // Retrieve the site information:
-      $details = mollom('mollom.getSite', array('client_key' => $site));
+      $details = mollom()->getSite(array('client_key' => $site));
 
       $this->assertEqual($details['mail'], 'mail@example.com', t('The original information is correctly retrieved from Mollom.'));
       $this->assertEqual($details['status'], 0, t('The original information is correctly retrieved from Mollom.'));
@@ -2593,8 +2801,8 @@ class MollomResellerTestCase extends MollomWebTestCase {
       // valid sites in case someone messed up their Mollom settings!
       if ($details['mail'] == 'mail@example.com' || $details['mail'] == 'root@example.com') {
         // Update the information on the site and verify that it was updated.
-        mollom('mollom.updateSite', array('client_key' => $site, 'mail' => 'root@example.com'));
-        $details = mollom('mollom.getSite', array('client_key' => $site));
+        mollom()->updateSite(array('client_key' => $site, 'mail' => 'root@example.com'));
+        $details = mollom()->getSite(array('client_key' => $site));
         $this->assertEqual($details['mail'], 'root@example.com', t('The updated information is correctly retrieved from Mollom.'));
 
         // Verify that the existing information did not change (partial updates).
@@ -2602,7 +2810,7 @@ class MollomResellerTestCase extends MollomWebTestCase {
         $this->assertEqual($details['testing'], 1, t('The original information is correctly retrieved from Mollom.'));
 
         // Delete the test site:
-        mollom('mollom.deleteSite', array('client_key' => $site));
+        mollom()->deleteSite(array('client_key' => $site));
       }
       else {
         $this->fail(t('We tried to delete a non-test site.'));
@@ -2613,7 +2821,7 @@ class MollomResellerTestCase extends MollomWebTestCase {
     $this->assertMollomWatchdogMessages();
 
     // Retrieve information about a non-existing site:
-    $details = mollom('mollom.getSite', array('client_key' => 'bogus'));
+    $details = mollom()->getSite(array('client_key' => 'bogus'));
     $this->assertEqual(xmlrpc_errno(), TRUE, t('Retrieving information from a non-existing site returned an XML-RPC error.'));
     $this->assertMollomWatchdogMessages(WATCHDOG_EMERGENCY);
 
@@ -2627,6 +2835,9 @@ class MollomResellerTestCase extends MollomWebTestCase {
  * Tests form value processing.
  */
 class MollomDataTestCase extends MollomWebTestCase {
+  // Re-route Mollom communication to this testing site.
+  protected $mollomClass = 'MollomDrupalTestLocal';
+
   public static function getInfo() {
     return array(
       'name' => 'Data processing',
@@ -2635,13 +2846,6 @@ class MollomDataTestCase extends MollomWebTestCase {
     );
   }
 
-  function setUp() {
-    // Enable testing server implementation.
-    parent::setUp(array('mollom_test'));
-    // Re-route Mollom communication to this testing site.
-    variable_set('mollom_servers', array($GLOBALS['base_url'] . '/xmlrpc.php?version='));
-  }
-
   /**
    * Test mollom_form_get_values().
    */
@@ -2693,20 +2897,20 @@ class MollomDataTestCase extends MollomWebTestCase {
     $form_state = array('values' => $values, 'buttons' => array());
     $data = mollom_form_get_values($form_state, $fields, $form_info['mapping']);
 
-    $this->assertSame('post_title', $data['post_title'], $values['subject']);
+    $this->assertSame('postTitle', $data['postTitle'], $values['subject']);
     $body = array(
       $values['message'],
       $values['parent']['child'],
       $values['field_checked'][0]['value'],
       $values['field_checked'][1]['value'],
     );
-    $this->assertSame('post_body', $data['post_body'], implode(" \n", $body));
-    $this->assertSame('author_name', $data['author_name'], $values['name']);
-    $this->assertSame('author_mail', $data['author_mail'], $values['mail']);
-    $this->assertFalse(isset($data['author_url']), t('author_url: Undefined.'));
-    $this->assertFalse(isset($data['author_openid']), t('author_openid: Undefined.'));
-    $this->assertFalse(isset($data['author_id']), t('author_id: Undefined.'));
-    $this->assertSame('author_ip', $data['author_ip'], ip_address());
+    $this->assertSame('postBody', $data['postBody'], implode(" \n", $body));
+    $this->assertSame('authorName', $data['authorName'], $values['name']);
+    $this->assertSame('authorMail', $data['authorMail'], $values['mail']);
+    $this->assertFalse(isset($data['authorUrl']), t('authorUrl: Undefined.'));
+    $this->assertFalse(isset($data['authorOpenid']), t('authorOpenid: Undefined.'));
+    $this->assertFalse(isset($data['authorId']), t('authorId: Undefined.'));
+    $this->assertSame('authorIp', $data['authorIp'], ip_address());
 
     // Verify submitted form values for an registered user.
     $user = $this->admin_user;
@@ -2718,15 +2922,15 @@ class MollomDataTestCase extends MollomWebTestCase {
     $form_state = array('values' => $values, 'buttons' => array());
     $data = mollom_form_get_values($form_state, $fields, $form_info['mapping']);
 
-    $this->assertSame('post_title', $data['post_title'], $values['subject']);
-    $this->assertSame('post_body', $data['post_body'], $values['message']);
-    $this->assertSame('author_name', $data['author_name'], $this->admin_user->name);
-    $this->assertSame('author_mail', $data['author_mail'], $this->admin_user->mail);
-    $this->assertFalse(isset($data['author_url']), t('author_url: Undefined.'));
+    $this->assertSame('postTitle', $data['postTitle'], $values['subject']);
+    $this->assertSame('postBody', $data['postBody'], $values['message']);
+    $this->assertSame('authorName', $data['authorName'], $this->admin_user->name);
+    $this->assertSame('authorMail', $data['authorMail'], $this->admin_user->mail);
+    $this->assertFalse(isset($data['authorUrl']), t('authorUrl: Undefined.'));
     // @todo Test this.
-    $this->assertFalse(isset($data['author_openid']), t('author_openid: Undefined.'));
-    $this->assertSame('author_id', $data['author_id'], $this->admin_user->uid);
-    $this->assertSame('author_ip', $data['author_ip'], ip_address());
+    $this->assertFalse(isset($data['authorOpenid']), t('authorOpenid: Undefined.'));
+    $this->assertSame('authorId', $data['authorId'], $this->admin_user->uid);
+    $this->assertSame('authorIp', $data['authorIp'], ip_address());
 
     // Verify that invalid UTF-8 is detected.
     $values = array(
@@ -2746,7 +2950,7 @@ class MollomDataTestCase extends MollomWebTestCase {
   }
 
   /**
-   * Test that form button values are not contained in post_body sent to Mollom.
+   * Test that form button values are not contained in postBody sent to Mollom.
    */
   function testFormButtonValues() {
     $this->drupalLogin($this->admin_user);
@@ -2761,7 +2965,7 @@ class MollomDataTestCase extends MollomWebTestCase {
     );
     $this->drupalPost('mollom-test/form', $edit, 'Submit');
     $data = $this->getServerRecord();
-    $this->assertFalse(preg_match('@Submit|Add@', $data['post_body']), 'Button values not found in post body.');
+    $this->assertFalse(preg_match('@Submit|Add@', $data['postBody']), 'Button values not found in post body.');
   }
 
   /**
@@ -2798,11 +3002,11 @@ class MollomDataTestCase extends MollomWebTestCase {
 
     // Verify that submitted data equals post data.
     $data = $this->getServerRecord();
-    $this->assertSame('post_title', $data['post_title'], $edit['subject']);
-    $this->assertSame('post_body', $data['post_body'], $edit['comment_body[und][0][value]']);
-    $this->assertSame('author_name', $data['author_name'], $this->web_user->name);
-    $this->assertSame('author_mail', $data['author_mail'], $this->web_user->mail);
-    $this->assertSame('author_id', $data['author_id'], $this->web_user->uid);
+    $this->assertSame('postTitle', $data['postTitle'], $edit['subject']);
+    $this->assertSame('postBody', $data['postBody'], $edit['comment_body[und][0][value]']);
+    $this->assertSame('authorName', $data['authorName'], $this->web_user->name);
+    $this->assertSame('authorMail', $data['authorMail'], $this->web_user->mail);
+    $this->assertSame('authorId', $data['authorId'], $this->web_user->uid);
     $this->assertSame('strictness', $data['strictness'], 'normal');
 
     $this->PostCorrectCaptcha(NULL, array(), t('Save'));
@@ -2811,7 +3015,7 @@ class MollomDataTestCase extends MollomWebTestCase {
 
     // Verify that submitted data equals post data.
     $data = $this->getServerRecord('mollom.checkCaptcha');
-    $this->assertSame('author_id', $data['author_id'], $this->web_user->uid);
+    $this->assertSame('authorId', $data['authorId'], $this->web_user->uid);
 
     // Allow anonymous users to post comments without approval.
     $this->drupalLogin($this->admin_user);
@@ -2846,12 +3050,12 @@ class MollomDataTestCase extends MollomWebTestCase {
 
     // Verify that submitted data equals post data.
     $data = $this->getServerRecord();
-    $this->assertSame('post_title', $data['post_title'], $edit['subject']);
-    $this->assertSame('post_body', $data['post_body'], $edit['comment_body[und][0][value]']);
-    $this->assertSame('author_name', $data['author_name'], $edit['name']);
-    $this->assertSame('author_mail', $data['author_mail'], $edit['mail']);
-    $this->assertSame('author_url', $data['author_url'], $edit['homepage']);
-    $this->assertFalse(isset($data['author_id']), t('author_id: Undefined.'));
+    $this->assertSame('postTitle', $data['postTitle'], $edit['subject']);
+    $this->assertSame('postBody', $data['postBody'], $edit['comment_body[und][0][value]']);
+    $this->assertSame('authorName', $data['authorName'], $edit['name']);
+    $this->assertSame('authorMail', $data['authorMail'], $edit['mail']);
+    $this->assertSame('authorUrl', $data['authorUrl'], $edit['homepage']);
+    $this->assertFalse(isset($data['authorId']), t('authorId: Undefined.'));
 
     $this->PostCorrectCaptcha(NULL, array(), t('Save'));
     $comment = db_query('SELECT * FROM {comment} WHERE subject = :subject', array(':subject' => $edit['subject']))->fetchObject();
@@ -2859,7 +3063,7 @@ class MollomDataTestCase extends MollomWebTestCase {
 
     // Verify that submitted data equals post data.
     $data = $this->getServerRecord('mollom.checkCaptcha');
-    $this->assertFalse(isset($data['author_id']), t('author_id: Undefined.'));
+    $this->assertFalse(isset($data['authorId']), t('authorId: Undefined.'));
 
     // Log in admin user and edit comment containing spam.
     $this->resetServerRecords();
@@ -2975,15 +3179,18 @@ class MollomDataTestCase extends MollomWebTestCase {
     $edit['title'] = 'unsure';
     $this->drupalPost(NULL, $edit, 'Submit');
     $this->assertCaptchaField();
+    $contentId = $this->assertResponseIDInForm('contentId');
+    $captchaId = $this->assertResponseIDInForm('captchaId');
     $this->postCorrectCaptcha(NULL, array(), 'Submit', 'Successful form submission.');
     $new_data = $this->assertMollomData('mollom_test', $mid);
 
     // Verify that only session data was updated.
     $this->assertSame('entity', $data->entity, $new_data->entity);
     $this->assertSame('id', $data->id, $new_data->id);
-    $this->assertNotSame('session_id', $data->session_id, $new_data->session_id);
+    $this->assertNotSame('contentId', $data->contentId, $new_data->contentId);
+    $this->assertNotSame('captchaId', $data->captchaId, $new_data->captchaId);
     $this->assertSame('form_id', $data->form_id, $new_data->form_id);
-    $this->assertSame('quality', $data->quality, $new_data->quality);
+    $this->assertSame('qualityScore', $data->qualityScore, $new_data->qualityScore);
     $count = db_query("SELECT COUNT(1) FROM {mollom}")->fetchField();
     $this->assertEqual($count, 1, t('Stored data in {mollom} was updated.'));
   }
@@ -2997,12 +3204,12 @@ class MollomDataTestCase extends MollomWebTestCase {
 
     // Verify that we additionally sent version data.
     $data = $this->getServerRecord('mollom.verifyKey');
-    $info = _mollom_get_version();
-    $this->assertTrue(!empty($info['platform_name']), t('Version information found.'));
-    $this->assertSame('platform_name', $data['platform_name'], $info['platform_name']);
-    $this->assertSame('platform_version', $data['platform_version'], $info['platform_version']);
-    $this->assertSame('client_name', $data['client_name'], $info['client_name']);
-    $this->assertSame('client_version', $data['client_version'], $info['client_version']);
+    $info = mollom()->getClientInformation();
+    $this->assertTrue(!empty($info['platformName']), t('Version information found.'));
+    $this->assertSame('platformName', $data['platformName'], $info['platformName']);
+    $this->assertSame('platformVersion', $data['platformVersion'], $info['platformVersion']);
+    $this->assertSame('clientName', $data['clientName'], $info['clientName']);
+    $this->assertSame('clientVersion', $data['clientVersion'], $info['clientVersion']);
   }
 }
 
@@ -3033,28 +3240,28 @@ class MollomDataCRUDTestCase extends MollomWebTestCase {
       'entity' => 'type1',
       'id' => 123,
       'form_id' => 'type1_form',
-      'session_id' => 'type1-session-id',
+      'contentId' => 1,
     );
     mollom_data_save($data1);
-    $this->assertMollomData($data1->entity, $data1->id, $data1->session_id);
+    $this->assertMollomData($data1->entity, $data1->id, 'contentId', $data1->contentId);
 
     // Create a second data record; same ID, different entity type.
     $data2 = (object) array(
       'entity' => 'type2',
       'id' => 123,
       'form_id' => 'type2_form',
-      'session_id' => 'type2-session-id',
+      'contentId' => 2,
     );
     mollom_data_save($data2);
-    $this->assertMollomData($data2->entity, $data2->id, $data2->session_id);
+    $this->assertMollomData($data2->entity, $data2->id, 'contentId', $data2->contentId);
 
     // Update the first data record.
-    $data1->session_id = 'new-session-id-type1';
+    $data1->contentId = 3;
     mollom_data_save($data1);
 
     // Verify that both records are correct.
-    $this->assertMollomData($data1->entity, $data1->id, $data1->session_id);
-    $this->assertMollomData($data2->entity, $data2->id, $data2->session_id);
+    $this->assertMollomData($data1->entity, $data1->id, 'contentId', $data1->contentId);
+    $this->assertMollomData($data2->entity, $data2->id, 'contentId', $data2->contentId);
   }
 
   /**
@@ -3066,7 +3273,7 @@ class MollomDataCRUDTestCase extends MollomWebTestCase {
       'entity' => 'type1',
       'id' => 123,
       'form_id' => 'type1_form',
-      'session_id' => 'type1-session-id',
+      'contentId' => 1,
     );
     mollom_data_save($data1);
 
@@ -3075,28 +3282,25 @@ class MollomDataCRUDTestCase extends MollomWebTestCase {
       'entity' => 'type2',
       'id' => 123,
       'form_id' => 'type2_form',
-      'session_id' => 'type2-session-id',
+      'contentId' => 2,
     );
     mollom_data_save($data2);
 
     // Verify that both records exist.
-    $this->assertMollomData($data1->entity, $data1->id, $data1->session_id);
-    $this->assertMollomData($data2->entity, $data2->id, $data2->session_id);
+    $this->assertMollomData($data1->entity, $data1->id, 'contentId', $data1->contentId);
+    $this->assertMollomData($data2->entity, $data2->id, 'contentId', $data2->contentId);
 
     // Delete the first data record.
     mollom_data_delete($data1->entity, $data1->id);
 
     // Verify that only the second record remained and was not changed.
     $this->assertNoMollomData($data1->entity, $data1->id);
-    $this->assertMollomData($data2->entity, $data2->id, $data2->session_id);
+    $this->assertMollomData($data2->entity, $data2->id, 'contentId', $data2->contentId);
   }
 }
 
 /**
  * Tests text analysis functionality.
- *
- * @todo Verify that no button captions appear in the data that is sent for
- *   analyis; i.e., no "Add" string for mollom_test_form.
  */
 class MollomAnalysisTestCase extends MollomWebTestCase {
   protected $profile = 'testing';
@@ -3151,8 +3355,9 @@ class MollomAnalysisTestCase extends MollomWebTestCase {
     $data = $this->assertMollomData('mollom_test', $mid);
     $record = mollom_test_load($mid);
     $this->assertEqual($record['status'], 0, t('Unpublished test post found.'));
-    $this->assertSame('spam', $data->spam, MOLLOM_ANALYSIS_SPAM);
-    $this->assertSame('profanity', $data->profanity, 1);
+    $this->assertSame('spamScore', $data->spamScore, 1.0);
+    $this->assertSame('spamClassification', $data->spamClassification, 'spam');
+    $this->assertSame('profanityScore', $data->profanityScore, 1);
     $this->assertSame('moderate', $data->moderate, 1);
 
     // Verify that editing the post does neither change the session data, nor
@@ -3166,8 +3371,9 @@ class MollomAnalysisTestCase extends MollomWebTestCase {
     $data = $this->assertMollomData('mollom_test', $mid);
     $record = mollom_test_load($mid);
     $this->assertEqual($record['status'], 0, t('Unpublished test post found.'));
-    $this->assertSame('spam', $data->spam, MOLLOM_ANALYSIS_SPAM);
-    $this->assertSame('profanity', $data->profanity, 1);
+    $this->assertSame('spamScore', $data->spamScore, 1.0);
+    $this->assertSame('spamClassification', $data->spamClassification, 'spam');
+    $this->assertSame('profanityScore', $data->profanityScore, 1);
     $this->assertSame('moderate', $data->moderate, 1);
 
     // Verify that publishing the post changes the session data accordingly.
@@ -3180,17 +3386,18 @@ class MollomAnalysisTestCase extends MollomWebTestCase {
     $data = $this->assertMollomData('mollom_test', $mid);
     $record = mollom_test_load($mid);
     $this->assertEqual($record['status'], 1, t('Published test post found.'));
-    $this->assertSame('spam', $data->spam, MOLLOM_ANALYSIS_SPAM);
-    $this->assertSame('profanity', $data->profanity, 1);
+    $this->assertSame('spamScore', $data->spamScore, 1.0);
+    $this->assertSame('spamClassification', $data->spamClassification, 'spam');
+    $this->assertSame('profanityScore', $data->profanityScore, 1);
     $this->assertSame('moderate', $data->moderate, 0);
 
     // Verify that neither ham or unsure spam posts, nor non-profane posts are
     // marked for moderation.
     $this->drupalLogout();
     $expectations = array(
-      'ham' => array('spam' => MOLLOM_ANALYSIS_HAM, 'profanity' => 0),
-      'unsure' => array('spam' => MOLLOM_ANALYSIS_UNSURE, 'profanity' => 0),
-      $this->randomString() => array('spam' => MOLLOM_ANALYSIS_UNSURE, 'profanity' => 0),
+      'ham' => array('spamScore' => 0.0, 'spamClassification' => 'ham', 'profanityScore' => 0),
+      'unsure' => array('spamScore' => 0.5, 'spamClassification' => 'unsure', 'profanityScore' => 0),
+      $this->randomString() => array('spamScore' => 0.5, 'spamClassification' => 'unsure', 'profanityScore' => 0),
     );
     foreach ($expectations as $body => $expected) {
       $edit = array(
@@ -3198,15 +3405,16 @@ class MollomAnalysisTestCase extends MollomWebTestCase {
         'body' => $body,
       );
       $this->drupalPost('mollom-test/form', $edit, 'Submit');
-      if ($expected['spam'] == MOLLOM_ANALYSIS_UNSURE) {
+      if ($expected['spamClassification'] == 'unsure') {
         $this->postCorrectCaptcha(NULL, array(), 'Submit');
       }
       $mid = $this->assertTestSubmitData();
       $data = $this->assertMollomData('mollom_test', $mid);
       $record = mollom_test_load($mid);
       $this->assertEqual($record['status'], 1, t('Published test post %body found.', array('%body' => $body)));
-      $this->assertSame('spam', $data->spam, $expected['spam']);
-      $this->assertSame('profanity', $data->profanity, $expected['profanity']);
+      $this->assertSame('spamScore', $data->spamScore, $expected['spamScore']);
+      $this->assertSame('spamClassification', $data->spamClassification, $expected['spamClassification']);
+      $this->assertSame('profanityScore', $data->profanityScore, $expected['profanityScore']);
       $this->assertSame('moderate', $data->moderate, 0);
     }
   }
diff --git tests/mollom_test.install tests/mollom_test.install
index b4bea98..8c3e9a1 100644
--- tests/mollom_test.install
+++ tests/mollom_test.install
@@ -42,3 +42,12 @@ function mollom_test_schema() {
   return $schema;
 }
 
+/**
+ * Implements hook_uninstall().
+ */
+function mollom_test_uninstall() {
+  db_delete('variable')
+    ->condition('name', db_like('mollom_test_') . '%', 'LIKE')
+    ->execute();
+}
+
diff --git tests/mollom_test.module tests/mollom_test.module
index d8d9192..1473294 100644
--- tests/mollom_test.module
+++ tests/mollom_test.module
@@ -3,6 +3,16 @@
 /**
  * @file
  * Testing functionality for Mollom module.
+ *
+ * @todo Extract testing server into a new mollom_test_server.module. The
+ *   mollom_test.module serves as good example for how to implement Mollom
+ *   support in a Drupal module, but 90% of it pertain to the testing server
+ *   now, so it's hard to explain people what they should look at.
+ */
+
+/**
+ * @defgroup mollom_test_xmlrpc Mollom XML-RPC fake server functions
+ * @{
  */
 
 /**
@@ -12,28 +22,504 @@ function mollom_test_xmlrpc() {
   return array(
     // $data contains a variable amount of properties, so we cannot specify a
     // signature.
-    'mollom.getServerList' => 'mollom_test_get_server_list',
-    'mollom.verifyKey' => 'mollom_test_verify_key',
-    'mollom.checkContent' => 'mollom_test_check_content',
-    'mollom.getImageCaptcha' => 'mollom_test_get_captcha',
-    'mollom.checkCaptcha' => 'mollom_test_check_captcha',
-    'mollom.sendFeedback' => 'mollom_test_send_feedback',
+    'mollom.getServerList' => 'mollom_test_xmlrpc_get_server_list',
+    'mollom.verifyKey' => 'mollom_test_xmlrpc_verify_key',
+    'mollom.checkContent' => 'mollom_test_xmlrpc_check_content',
+    'mollom.getImageCaptcha' => 'mollom_test_xmlrpc_get_captcha',
+    'mollom.checkCaptcha' => 'mollom_test_xmlrpc_check_captcha',
+    'mollom.sendFeedback' => 'mollom_test_xmlrpc_send_feedback',
  );
 }
 
 /**
+ * Converts camelCase request/response parameters to lowercase with underscores.
+ *
+ * @todo Recurse into multi-dimensional arrays.
+ */
+function mollom_test_xmlrpc_convert_params(array $data = array()) {
+  foreach ($data as $key => $value) {
+    // Convert CamelCase to lowercase with underscores.
+    $new_key = strtolower(preg_replace('@(?<=[a-z])([A-Z])@', '_$1', $key));
+    $data[$new_key] = $value;
+    unset($data[$key]);
+  }
+  return $data;
+}
+
+/**
  * XML-RPC callback for mollom.getServerList to retrieve new server list.
  */
+function mollom_test_xmlrpc_get_server_list($data) {
+  $servers = mollom_test_get_server_list($data);
+  foreach ($servers as $key => $url) {
+    $servers[$key] .= '/xmlrpc.php?version=';
+  }
+  return $servers;
+}
+
+/**
+ * XML-RPC callback for mollom.verifyKey to validate API keys.
+ */
+function mollom_test_xmlrpc_verify_key($data) {
+  $valid = mollom_test_verify_key($data);
+  if ($valid) {
+    return TRUE;
+  }
+  xmlrpc_error(Mollom::AUTH_ERROR);
+}
+
+/**
+ * XML-RPC callback for mollom.checkContent to perform textual analysis.
+ */
+function mollom_test_xmlrpc_check_content($data) {
+  return mollom_test_check_content($data);
+}
+
+/**
+ * XML-RPC callback for mollom.getImageCaptcha to fetch a CATPCHA image.
+ */
+function mollom_test_xmlrpc_get_captcha($data) {
+  return mollom_test_get_captcha($data);
+}
+
+/**
+ * XML-RPC callback for mollom.checkCaptcha to validate a CAPTCHA response.
+ */
+function mollom_test_xmlrpc_check_captcha($data) {
+  if (isset($data['captcha_result'])) {
+    $data['solution'] = $data['captcha_result'];
+  }
+
+  return mollom_test_check_captcha($data);
+}
+
+/**
+ * XML-RPC callback for mollom.sendFeedback to send feedback for a moderated post.
+ */
+function mollom_test_xmlrpc_send_feedback($data) {
+  $result = mollom_test_send_feedback($data);
+  if ($result) {
+    return TRUE;
+  }
+  xmlrpc_error(Mollom::AUTH_ERROR);
+}
+
+/**
+ * @} End of "defgroup mollom_test_xmlrpc".
+ */
+
+/**
+ * @defgroup mollom_test_rest Mollom REST fake server functions
+ * @{
+ */
+
+/**
+ * Implements hook_menu() for REST API endpoints.
+ */
+function mollom_test_rest() {
+  $path = 'mollom-test/rest/v1';
+  $base_args = count(explode('/', $path)) - 1;
+  // @todo Consider to use a generic page callback, passing arg(3), the resource
+  //   type, and optionally arg(4), the resource, as argument. This would allow
+  //   us to use PHP Exceptions to throw different status codes and errors. Make
+  //   that page callback dynamically switch the delivery callback (for JSON).
+  $base = array(
+    'access callback' => TRUE,
+    'type' => MENU_CALLBACK,
+    'delivery callback' => 'mollom_test_rest_deliver',
+  );
+
+  $items[$path . '/site'] = $base + array(
+    'page callback' => 'mollom_test_rest_site',
+  );
+  $items[$path . '/content'] = $base + array(
+    'page callback' => 'mollom_test_rest_content',
+  );
+  $items[$path . '/captcha'] = $base + array(
+    'page callback' => 'mollom_test_rest_captcha',
+  );
+  $items[$path . '/feedback'] = $base + array(
+    'page callback' => 'mollom_test_rest_send_feedback',
+  );
+  $items[$path . '/blacklist/%'] = $base + array(
+    'page callback' => 'mollom_test_rest_blacklist',
+    'page arguments' => array($base_args + 2),
+  );
+  // @todo Whitelist endpoints.
+
+  return $items;
+}
+
+/**
+ * Returns HTTP request query parameters for the current request.
+ *
+ * @see Mollom::httpBuildQuery()
+ * @see http://php.net/manual/en/wrappers.php.php
+ */
+function mollom_test_rest_get_parameters() {
+  if ($_SERVER['REQUEST_METHOD'] == 'GET' || $_SERVER['REQUEST_METHOD'] == 'HEAD') {
+    $data = Mollom::httpParseQuery($_SERVER['QUERY_STRING']);
+  }
+  elseif ($_SERVER['REQUEST_METHOD'] == 'POST' || $_SERVER['REQUEST_METHOD'] == 'PUT') {
+    $data = Mollom::httpParseQuery(file_get_contents('php://input'));
+  }
+  return $data;
+}
+
+/**
+ * Delivery callback for REST API endpoints.
+ */
+function mollom_test_rest_deliver($page_callback_result) {
+  #drupal_add_http_header('Content-Type', 'application/xml; charset=utf-8');
+  drupal_add_http_header('Content-Type', 'application/xml');
+
+  $xml = new DOMDocument('1.0', 'utf-8');
+  $element = $xml->createElement('response');
+
+  // Append status response parameters.
+  // @todo Add support for custom codes (redirect/refresh) + error messages.
+  $code = 200;
+  if (!is_array($page_callback_result) && $page_callback_result !== TRUE) {
+    switch ($page_callback_result) {
+      case MENU_NOT_FOUND:
+        $code = 404;
+        $message = 'Not found';
+        break;
+
+      case Mollom::AUTH_ERROR:
+        $code = 1000;
+        $message = 'Authentication failure';
+        break;
+
+      default:
+        $code = 400;
+        $message = 'Bad request';
+        break;
+    }
+  }
+  $status = array(
+    'code' => $code,
+  );
+  if (isset($message)) {
+    $status['message'] = $message;
+  }
+  mollom_test_rest_add_xml($xml, $element, $status);
+
+  // Append other response parameters.
+  if (is_array($page_callback_result)) {
+    mollom_test_rest_add_xml($xml, $element, $page_callback_result);
+  }
+
+  $xml->appendChild($element);
+  print $xml->saveXML();
+
+  // Perform end-of-request tasks.
+  drupal_page_footer();
+}
+
+function mollom_test_rest_add_xml(DOMDocument $doc, DOMNode $parent, $data, $key = NULL) {
+  if (is_scalar($data)) {
+    // Mollom REST API always uses integers instead of Booleans due to varying
+    // implementations of JSON protocol across client platforms/frameworks.
+    if (is_bool($data)) {
+      $data = (int) $data;
+    }
+
+    $element = $doc->createTextNode($data);
+    $parent->appendChild($element);
+  }
+  else {
+    foreach ($data as $property => $value) {
+      $key = (is_numeric($property) ? 'item' : $property);
+      $element = $doc->createElement($key);
+      $parent->appendChild($element);
+      mollom_test_rest_add_xml($doc, $element, $value, $key);
+    }
+  }
+}
+
+/**
+ * REST callback for CRUD site operations.
+ *
+ * @param $publicKey
+ *   (optional) The public key of a site.
+ * @param $delete
+ *   (optional) Whether to delete the site with $publicKey.
+ */
+function mollom_test_rest_site($publicKey = NULL, $delete = FALSE) {
+  $data = mollom_test_rest_get_parameters();
+
+  // Prepare server list.
+  $servers = mollom_test_get_server_list($data);
+  foreach ($servers as $key => &$url) {
+    $url .= '/mollom-test/rest/' . Mollom::API_VERSION;
+  }
+
+  $bin = 'mollom_test_site';
+  $sites = variable_get($bin, array());
+
+  // Check whether publicKey exists.
+  if (isset($publicKey)) {
+    if (!isset($sites[$publicKey])) {
+      return MENU_NOT_FOUND;
+    }
+    // Validate authentication.
+    $time = $data['time'];
+    $nonce = $data['nonce'];
+    $request_data = $time . ':' . $nonce . ':' . $sites[$publicKey]['privateKey'];
+    $hash = base64_encode(hash_hmac('sha1', $request_data, $sites[$publicKey]['privateKey'], TRUE));
+    if ($hash !== $data['hash']) {
+      return Mollom::AUTH_ERROR;
+    }
+  }
+
+  if ($_SERVER['REQUEST_METHOD'] == 'GET') {
+    // Return existing site.
+    if (isset($publicKey)) {
+      $response = $sites[$publicKey];
+    }
+    // Return list of existing sites.
+    else {
+      $response = array(
+        'list' => array_values($sites),
+        'listCount' => count($sites),
+        'listOffset' => 0,
+        'listTotal' => count($sites),
+      );
+      return $response;
+    }
+  }
+  else {
+    // Update site.
+    if (isset($publicKey) && !$delete) {
+      // Remove authentication parameters.
+      unset($data['publicKey'], $data['time'], $data['hash'], $data['nonce']);
+
+      $storage = variable_get('mollom_test_verify_key', array());
+      $storage[] = $data;
+      variable_set('mollom_test_verify_key', $storage);
+
+      $sites[$publicKey] = $data + $sites[$publicKey];
+      variable_set($bin, $sites);
+      $response = $sites[$publicKey];
+    }
+    // Create new site.
+    // Authentication is ignored in this case.
+    elseif (!$delete) {
+      // Remove authentication parameters.
+      unset($data['publicKey'], $data['time'], $data['hash'], $data['nonce']);
+
+      $data['publicKey'] = $publicKey = md5(rand() . REQUEST_TIME);
+      $data['privateKey'] = $privateKey = md5(rand() . REQUEST_TIME);
+      // Apply default values.
+      $data += array(
+        'url' => '',
+        'email' => '',
+        'languages' => array(),
+        'subscriptionType' => 0, // Mollom Free.
+        // Client version info is not defined by default.
+        /*
+        'platformName' => '',
+        'platformVersion' => '',
+        'clientName' => '',
+        'clientVersion' => '',
+        */
+        'servers' => $servers,
+      );
+      $sites[$publicKey] = $data;
+      variable_set($bin, $sites);
+      $response = $data;
+    }
+    // Delete site.
+    else {
+      unset($sites[$publicKey]);
+      variable_set($bin, $sites);
+      return TRUE;
+    }
+  }
+  return array('site' => $response);
+}
+
+/**
+ * REST callback for mollom.checkContent to perform textual analysis.
+ */
+function mollom_test_rest_content($contentId = NULL) {
+  $data = mollom_test_rest_get_parameters();
+  if ($_SERVER['REQUEST_METHOD'] == 'GET') {
+    // @todo List/read content.
+    if (empty($contentId)) {
+      return FALSE;
+    }
+    return FALSE;
+  }
+  else {
+    // Update existing content (includes sending feedback).
+    // In case the 'moderated' parameter was passed, the call equals the old
+    // mollom.sendFeedback and we only check whether the parameter value is
+    // correct.
+    if (isset($data['moderated'])) {
+      $valid = is_string($data['moderated']);
+      $valid = $valid && in_array($data['moderated'], array('spam', 'profanity', 'low-quality', 'unwanted', 'approve', 'escalate', 'delete', 'ignore'));
+      return $valid;
+    }
+    // Content ID in request parameters must match the one in path.
+    if (isset($data['id']) && $data['id'] != $contentId) {
+      return FALSE;
+    }
+  }
+
+  // Default POST: Create or update content and check it.
+  return array('content' => mollom_test_check_content($data));
+}
+
+/**
+ * REST callback for mollom.getCaptcha to fetch a CAPTCHA.
+ */
+function mollom_test_rest_captcha($captchaId = NULL) {
+  $data = mollom_test_rest_get_parameters();
+  if ($_SERVER['REQUEST_METHOD'] == 'GET') {
+    // There is no GET /captcha[/{captchaId}].
+    return FALSE;
+  }
+  else {
+    // CAPTCHA ID in request parameters must match the one in path.
+    if (isset($data['id']) && $data['id'] != $captchaId) {
+      return FALSE;
+    }
+    // Verify CAPTCHA.
+    if (isset($data['id'])) {
+      return array('captcha' => mollom_test_check_captcha($data));
+    }
+  }
+  // Create a new CAPTCHA resource.
+  return array('captcha' => mollom_test_get_captcha($data));
+}
+
+/**
+ * REST callback for Blacklist API.
+ *
+ * @param $public_key
+ *   The public key of a site.
+ *
+ * @todo Abstract actual functionality like other REST handlers.
+ */
+function mollom_test_rest_blacklist($public_key, $entryId = NULL, $delete = FALSE) {
+  if (empty($public_key)) {
+    return FALSE;
+  }
+  $data = mollom_test_rest_get_parameters();
+  // Remove authentication parameters.
+  unset($data['publicKey'], $data['time'], $data['hash'], $data['nonce']);
+
+  // Prepare text value.
+  if (isset($data['value'])) {
+    $data['value'] = drupal_strtolower(trim($data['value']));
+  }
+
+  $bin = 'mollom_test_blacklist_' . $public_key;
+  $entries = variable_get($bin, array());
+
+  if ($_SERVER['REQUEST_METHOD'] == 'GET') {
+    // List blacklist entries.
+    if (empty($entryId)) {
+      $response = array();
+      // Remove deleted entries (== FALSE).
+      $entries = array_filter($entries);
+      $response['list'] = $entries;
+      // @todo Not required yet.
+      $response['listCount'] = count($entries);
+      $response['listOffset'] = 0;
+      $response['listTotal'] = count($entries);
+      return $response;
+    }
+    // Read a single entry.
+    else {
+      // Check whether the entry exists and was not deleted.
+      if (!empty($entries[$entryId])) {
+        return array('entry' => $entries[$entryId]);
+      }
+      else {
+        return MENU_NOT_FOUND;
+      }
+    }
+  }
+  else {
+    // Update an existing entry.
+    if (isset($entryId)) {
+      // Entry ID must match.
+      if (isset($data['id']) && $data['id'] != $entryId) {
+        return FALSE;
+      }
+      // Check that the entry was not deleted.
+      if (empty($entries[$entryId])) {
+        return MENU_NOT_FOUND;
+      }
+      // Entry ID cannot be updated.
+      unset($data['id']);
+      $entries[$entryId] = $data;
+      variable_set($bin, $entries);
+      $response = $data;
+      $response['id'] = $entryId;
+      return array('entry' => $response);
+    }
+    // Create a new entry.
+    elseif (!$delete) {
+      $entryId = max(array_keys($entries)) + 1;
+      $data['id'] = $entryId;
+      $entries[$entryId] = $data;
+      variable_set($bin, $entries);
+
+      $response = $data;
+      return array('entry' => $response);
+    }
+    // Delete an existing entry.
+    else {
+      // Check that the entry was not deleted already.
+      if (!empty($entries[$entryId])) {
+        $entries[$entryId] = FALSE;
+        variable_set($bin, $entries);
+        return TRUE;
+      }
+      else {
+        return MENU_NOT_FOUND;
+      }
+    }
+  }
+}
+
+/**
+ * REST callback for mollom.sendFeedback to send feedback for a moderated post.
+ */
+function mollom_test_rest_send_feedback() {
+  $data = mollom_test_rest_get_parameters();
+  // A resource ID is required.
+  if (empty($data['contentId']) && empty($data['captchaId'])) {
+    return 400;
+  }
+
+  // The feedback is valid if the supplied reason is one of the supported
+  // strings. Otherwise, it's a bad request.
+  $result = mollom_test_send_feedback($data);
+  return $result ? TRUE : 400;
+}
+
+/**
+ * @} End of "defgroup mollom_test_rest".
+ */
+
+/**
+ * API callback for mollom.getServerList to retrieve new server list.
+ */
 function mollom_test_get_server_list($data) {
   $storage = variable_get(__FUNCTION__, array());
   $storage[] = $data;
   variable_set(__FUNCTION__, $storage);
 
-  return array($GLOBALS['base_url'] . '/xmlrpc.php?version=');
+  return array($GLOBALS['base_url'], $GLOBALS['base_url']);
 }
 
 /**
- * XML-RPC callback for mollom.verifyKey to validate API keys.
+ * API callback for mollom.verifyKey to validate API keys.
  */
 function mollom_test_verify_key($data) {
   $storage = variable_get(__FUNCTION__, array());
@@ -44,167 +530,232 @@ function mollom_test_verify_key($data) {
   module_load_include('php', 'simpletest', 'drupal_web_test_case');
   module_load_include('test', 'mollom', 'tests/mollom');
 
-  if ($data['public_key'] === MOLLOM_TEST_PUBLIC_KEY) {
-    return TRUE;
-  }
-  xmlrpc_error(MOLLOM_ERROR);
+  return $data['public_key'] === MOLLOM_TEST_PUBLIC_KEY;
 }
 
 /**
- * XML-RPC callback for mollom.checkContent to perform textual analysis.
+ * API callback for mollom.checkContent to perform textual analysis.
  *
  * @todo Add support for 'redirect' and 'refresh' values.
  */
 function mollom_test_check_content($data) {
-  $storage = variable_get(__FUNCTION__, array());
-  $storage[] = $data;
-  variable_set(__FUNCTION__, $storage);
-
   $response = array();
 
+  // If only a single value for checks is passed, it is a string.
+  if (isset($data['checks']) && is_string($data['checks'])) {
+    $data['checks'] = array($data['checks']);
+  }
+
+  // Fetch blacklist.
+  $blacklist = variable_get('mollom_test_blacklist_' . $data['publicKey'], array());
+
+  $post = implode('\n', array_intersect_key($data, array('postTitle' => 1, 'postBody' => 1)));
+
   // Spam filter: Check post_title and post_body for ham, spam, or unsure.
-  if (!isset($data['checks']) || strpos($data['checks'], 'spam') !== FALSE) {
+  if (!isset($data['checks']) || in_array('spam', $data['checks'])) {
     $spam = FALSE;
     $ham = FALSE;
-    foreach (array('post_title', 'post_body') as $key) {
-      if (!isset($data[$key])) {
-        continue;
-      }
-      // 'spam' always has precedence.
-      if (strpos($data[$key], 'spam') !== FALSE) {
-        $spam = TRUE;
-      }
-      // Otherwise, check for 'ham'.
-      elseif (strpos($data[$key], 'ham') !== FALSE) {
-        $ham = TRUE;
-      }
-      // Lastly, take a forced 'unsure' into account.
-      elseif (strpos($data[$key], 'unsure') !== FALSE) {
-        $spam = TRUE;
-        $ham = TRUE;
-      }
+    // 'spam' always has precedence.
+    if (strpos($post, 'spam') !== FALSE) {
+      $spam = TRUE;
     }
+    // Otherwise, check for 'ham'.
+    elseif (strpos($post, 'ham') !== FALSE) {
+      $ham = TRUE;
+    }
+    // Lastly, take a forced 'unsure' into account.
+    elseif (strpos($post, 'unsure') !== FALSE) {
+      $spam = TRUE;
+      $ham = TRUE;
+    }
+    // Check blacklist.
+    if ($matches = mollom_test_check_content_blacklist($post, $blacklist, 'spam')) {
+      $spam = TRUE;
+      $ham = FALSE;
+      $response['blacklistSpam'] = $matches;
+    }
+
     if ($spam && $ham) {
-      $response['spam'] = MOLLOM_ANALYSIS_UNSURE;
-      $quality = 0.5;
+      $response['spamScore'] = 0.5;
+      $response['spamClassification'] = 'unsure';
+      $qualityScore = 0.5;
     }
     elseif ($spam) {
-      $response['spam'] = MOLLOM_ANALYSIS_SPAM;
-      $quality = 0;
+      $response['spamScore'] = 1.0;
+      $response['spamClassification'] = 'spam';
+      $qualityScore = 0.0;
     }
     elseif ($ham) {
-      $response['spam'] = MOLLOM_ANALYSIS_HAM;
-      $quality = 1;
+      $response['spamScore'] = 0.0;
+      $response['spamClassification'] = 'ham';
+      $qualityScore = 1.0;
     }
     else {
-      $response['spam'] = MOLLOM_ANALYSIS_UNSURE;
-      $quality = NULL;
+      $response['spamScore'] = 0.5;
+      $response['spamClassification'] = 'unsure';
+      $qualityScore = NULL;
     }
     // In case a previous spam check was unsure and a CAPTCHA was solved, the
     // result is supposed to be ham.
     $captcha_sessions = variable_get('mollom_test_check_captcha_sessions', array());
-    if (!empty($data['session_id']) && !empty($captcha_sessions[$data['session_id']])) {
-      $response['spam'] = MOLLOM_ANALYSIS_HAM;
+    if (!empty($data['captchaId']) && !empty($captcha_sessions[$data['captchaId']])) {
+      $response['spamScore'] = 0.0;
+      $response['spamClassification'] = 'ham';
     }
   }
 
   // Quality filter.
-  if (!isset($data['checks']) || strpos($data['checks'], 'quality') !== FALSE) {
-    if (isset($quality)) {
-      $response['quality'] = $quality;
+  if (!isset($data['checks']) || in_array('quality', $data['checks'])) {
+    if (isset($qualityScore)) {
+      $response['qualityScore'] = $qualityScore;
     }
-    // @todo No idea how quality is calculated during testing without spam
-    //   results above.
     else {
-      $response['quality'] = 0;
+      $response['qualityScore'] = 0;
     }
   }
 
   // Profanity filter.
-  if (isset($data['checks']) && strpos($data['checks'], 'profanity') !== FALSE) {
-    $profanity = 0.0;
-    foreach (array('post_title', 'post_body') as $key) {
-      if (isset($data[$key]) && strpos($data[$key], 'profanity') !== FALSE) {
-        $profanity = 1.0;
-      }
+  if (isset($data['checks']) && in_array('profanity', $data['checks'])) {
+    $profanityScore = 0.0;
+    if (strpos($post, 'profanity') !== FALSE) {
+      $profanityScore = 1.0;
+    }
+    // Check blacklist.
+    if ($matches = mollom_test_check_content_blacklist($post, $blacklist, 'profanity')) {
+      $profanityScore = 1.0;
+      $response['blacklistProfanity'] = $matches;
     }
-    $response['profanity'] = $profanity;
+    $response['profanityScore'] = $profanityScore;
   }
 
-  if (!empty($data['session_id'])) {
-    $response['session_id'] = $data['session_id'];
-  }
-  else {
-    drupal_session_start();
-    $response['session_id'] = session_id();
+  // Language detection.
+  if (isset($data['checks']) && in_array('language', $data['checks'])) {
+    $languages = array();
+    preg_match_all('@\blang-(..)\b@', $post, $matches);
+    if (empty($matches[1])) {
+      $languages[] = array(
+        'languageCode' => 'zxx',
+        'confidence' => 1.0,
+      );
+    }
+    elseif (count($matches[1]) > 3) {
+      $languages[] = array(
+        'languageCode' => LANGUAGE_NONE,
+        'confidence' => 1.0,
+      );
+    }
+    else {
+      $confidence = 1 / count($matches[1]);
+      foreach ($matches[1] as $language) {
+        $languages[] = array(
+          'languageCode' => $language,
+          'confidence' => $confidence,
+        );
+      }
+    }
+    $response['languages'] = $languages;
+    $response['langDebug'] = $matches;
   }
 
+  $storage = variable_get(__FUNCTION__, array());
+  $contentId = (!empty($data['id']) ? (int) $data['id'] : max(array_keys($storage)) + 1);
+  $storage[$contentId] = $data;
+  $response['id'] = $contentId;
+  variable_set(__FUNCTION__, $storage);
+
   return $response;
 }
 
 /**
- * XML-RPC callback for mollom.getImageCaptcha to fetch a CATPCHA image.
+ * Checks a string against blacklisted terms.
  */
-function mollom_test_get_captcha($data) {
-  $storage = variable_get(__FUNCTION__, array());
-  $storage[] = $data;
-  variable_set(__FUNCTION__, $storage);
+function mollom_test_check_content_blacklist($string, $blacklist, $reason) {
+  $terms = array();
+  foreach ($blacklist as $entry) {
+    if ($entry['reason'] == $reason) {
+      $term = preg_quote($entry['value']);
+      if ($entry['match'] == 'exact') {
+        $term = '\b' . $term . '\b';
+      }
+      $terms[] = $term;
+    }
+  }
+  if (!empty($terms)) {
+    $terms = '/(' . implode('|', $terms) . ')/';
+    preg_match_all($terms, strtolower($string), $matches);
+    return $matches[1];
+  }
+  return array();
+}
 
-  drupal_session_start();
+/**
+ * API callback for mollom.getImageCaptcha to fetch a CATPCHA image.
+ */
+function mollom_test_get_captcha($data) {
+  $response = array();
 
   // Return a HTTPS URL if 'ssl' parameter was passed.
   $base_url = $GLOBALS['base_url'];
   if (!empty($data['ssl'])) {
     $base_url = str_replace('http', 'https', $base_url);
   }
+  $response['url'] = $base_url . '/' . drupal_get_path('module', 'mollom') . '/images/powered-by-mollom-2.gif';
 
-  return array(
-    'session_id' => !empty($data['session_id']) ? $data['session_id'] : session_id(),
-    'url' => $base_url . '/' . drupal_get_path('module', 'mollom') . '/images/powered-by-mollom-2.gif',
-  );
+  $storage = variable_get(__FUNCTION__, array());
+  $captchaId = (!empty($data['id']) ? (int) $data['id'] : max(array_keys($storage)) + 1);
+  $storage[$captchaId] = $data;
+  $response['id'] = $captchaId;
+  variable_set(__FUNCTION__, $storage);
+
+  return $response;
 }
 
 /**
- * XML-RPC callback for mollom.checkCaptcha to validate a CAPTCHA response.
+ * API callback for mollom.checkCaptcha to validate a CAPTCHA response.
  *
  * @todo Add support for 'redirect' and 'refresh' values.
  */
 function mollom_test_check_captcha($data) {
-  $storage = variable_get(__FUNCTION__, array());
-  $storage[] = $data;
-  variable_set(__FUNCTION__, $storage);
+  $response = array();
 
-  if ($data['captcha_result'] == 'correct') {
-    $result = TRUE;
+  if (isset($data['solution']) && $data['solution'] == 'correct') {
+    $response['solved'] = TRUE;
   }
-  if ($data['captcha_result'] == 'incorrect') {
-    $result = FALSE;
+  else {
+    $response['solved'] = FALSE;
+    $response['reason'] = '';
   }
+
+  $storage = variable_get(__FUNCTION__, array());
+  $captchaId = (!empty($data['id']) ? (int) $data['id'] : max(array_keys($storage)) + 1);
+  $storage[$captchaId] = $data;
+  $response['id'] = $captchaId;
+  variable_set(__FUNCTION__, $storage);
+
   $captcha_sessions = variable_get('mollom_test_check_captcha_sessions', array());
-  $captcha_sessions[$data['session_id']] = $result;
+  $captcha_sessions[$captchaId] = $response['solved'];
   variable_set('mollom_test_check_captcha_sessions', $captcha_sessions);
 
-  return $result;
+  return $response;
 }
 
 /**
- * XML-RPC callback for mollom.sendFeedback to send feedback for a moderated post.
+ * API callback for mollom.sendFeedback to send feedback for a moderated post.
  */
 function mollom_test_send_feedback($data) {
   $storage = variable_get(__FUNCTION__, array());
   $storage[] = $data;
   variable_set(__FUNCTION__, $storage);
 
-  if (in_array($data['feedback'], array('spam', 'profanity', 'low-quality', 'unwanted', 'ham'))) {
-    return TRUE;
-  }
-  xmlrpc_error(MOLLOM_ERROR);
+  return in_array($data['reason'], array('spam', 'profanity', 'quality', 'unwanted', 'approve', 'delete'));
 }
 
 /**
  * Implements hook_menu().
  */
 function mollom_test_menu() {
+  $items = mollom_test_rest();
+
   $items['mollom-test/form'] = array(
     'title' => 'Mollom test form',
     'page callback' => 'drupal_get_form',
