diff --git a/README.txt b/README.txt
index 76de5a5..9d2e347 100644
--- a/README.txt
+++ b/README.txt
@@ -2,11 +2,15 @@ This module allows teams to track outstanding items which need
 resolution. It provides e-mail notifications to members about updates
 to items.  Similar to many issue tracking systems.
 
+INSTALLATION AND UPGRADE
+--------------------------
+
 For installation instructions, see INSTALL.txt.
 
 For instructions when upgrading to newer versions, see UPGRADE.txt.
 
-CAVEATS:
+CAVEATS
+-----------
 
 The filter which automatically turns references to project issues into
 links conflicts with filter caching in the following ways:
@@ -24,7 +28,22 @@ Send feature requests and bug reports to the issue tracking system for
 the project module: http://drupal.org/node/add/project_issue/project_issue.
 A TODO list can be found at http://groups.drupal.org/node/5489
 
+JSON WEB SERVICE
+-----------------
+In addition to an HTML page at node/[nid], each issue's metadata may be
+viewed in JSON format at node/[nid]/project-issue/json. The following
+querystring paramters are supported:
+
+api_version=[major]: If passed, the specified major version of the JSON API
+                       will be used for the call. If not passed, then the
+                       newest version of the API will be used.
+
+The current API Version is 1.
+
+Modules may alter the JSON with hook_project_issue_json_alter(). See project_issue.api.php.
+
+MAINTAINENCE
+----------------
 The project family of modules is currently being co-maintained by:
 - Derek Wright (http://drupal.org/user/46549) a.k.a. "dww"
 - Chad Phillips (http://drupal.org/user/22079) a.k.a. "hunmonk"
-
diff --git a/includes/project_issue.json.inc b/includes/project_issue.json.inc
new file mode 100644
index 0000000..e692c21
--- /dev/null
+++ b/includes/project_issue.json.inc
@@ -0,0 +1,71 @@
+<?php
+
+/**
+ * Menu callback. Returns JSON for an issue node.
+ *
+ * @todo
+ * When the major API version of this callback changes, it will need to be
+ * refactored. See http://drupal.org/node/112805#comment-5469522.
+ *
+ * @param $issue
+ *   A node of type project_issue.
+ * @return void
+ */
+function project_issue_json($issue) {
+  if ($project = node_load($issue->project_issue['pid'])) {
+    $release = isset($issue->project_issue['rid']) ? node_load($issue->project_issue['rid']) : NULL;
+    $assigned_user = isset($issue->project_issue['assigned']) ? user_load($issue->project_issue['assigned']) : NULL;
+    $submitter_user = isset($issue->uid) ? user_load($issue->uid) : NULL;
+    $files = array();
+    // These are attachments to the issue (as opposed to attachments on comments).
+    foreach ($issue->files as $fid => $file) {
+      $files[$fid] = file_create_url($file->filepath);
+    }
+    $options = array('absolute' => TRUE);
+    $return = array(
+      // Bump minor version when you add items. Bump major version when breaking backwards compatibility.
+      'json-version' => '1.0',
+      'title' => $issue->title,
+      'id' => $issue->nid,
+      'created' => $issue->created,
+      'changed' => $issue->changed,
+      'submitter-id' => $issue->uid,
+      'submitter-name' => isset($submitter_user->name) ? $submitter_user->name : NULL,
+      'submitter-url' => isset($submitter_user->name) ? url('user/' . $submitter_user->uid, $options) : NULL,
+      'url' => url('node/' . $issue->nid, $options),
+      'component' => $issue->project_issue['component'],
+      'category' => $issue->project_issue['category'],
+      'priority-id' => $issue->project_issue['priority'],
+      'priority' => project_issue_priority($issue->project_issue['priority']),
+      'version-id' => isset($release->nid) ? $release->nid : NULL,
+      'version' => isset($release->title) ? $release->title : NULL,
+      'assigned-id' => empty($assigned_user->uid) ? NULL : $assigned_user->uid,
+      'assigned-name' => empty($assigned_user->name) ? NULL : $assigned_user->name,
+      'assigned-url' => empty($assigned_user->uid) ? NULL : url('user/' . $assigned_user->uid, $options),
+      'status-id' => $issue->project_issue['sid'],
+      'status' => project_issue_state($issue->project_issue['sid']),
+      'files' => $files,
+      'project-id' => $project->nid,
+      'project-title' => $project->title,
+      'project-url' => url('node/' . $project->nid, $options),
+      'project-name' => $project->project['uri'],
+    );
+    $result = db_query("SELECT c.cid, c.status, c.timestamp, c.thread, u.name, u.uid FROM {comments} c INNER JOIN {users} u ON c.uid = u.uid WHERE c.nid = %d AND c.status = %d ORDER BY c.cid ASC", $issue->nid, COMMENT_PUBLISHED);
+    while ($row = db_fetch_object($result)) {
+      $return['contributors'][$row->uid] = array(
+        'name' => $row->name,
+        'uid' => $row->uid,
+        'url' => url('user/' . $row->uid, $options),
+      );
+      $return['comments'][$row->cid] = array(
+        'contributor' => $row->uid,
+        'url' => url('node/' . $issue->nid, array('absolute' => TRUE, 'fragment' => 'comment-' . $row->cid)),
+        'created' => $row->timestamp,
+        'status' => $row->status,
+        'thread' => $row->thread,
+      );
+    }
+    drupal_alter('project_issue_json', $return);
+    drupal_json($return);
+  }
+}
diff --git a/project_issue.api.php b/project_issue.api.php
index a0b6176..2963187 100644
--- a/project_issue.api.php
+++ b/project_issue.api.php
@@ -21,3 +21,23 @@
 function hook_project_issue_internal_links_alter(&$links, $node) {
   $links[] = l(t('Most excellent comment'), "node/$node->nid", array('fragment' => 'excellent')); // If only it were so easy. ;)
 }
+
+/**
+ * Alter hook for the data sent as JSON for a given issue.
+ *
+ * @param array $issue
+ *   A PHP array containing basic information about an issue. Modules can
+ *   add/alter this array to enrich the data that's presented. Only in rare cases
+ *   should a module change json-version value (an API change for consumers).
+ *
+ * @see project_issue_json()
+ * @see drupal_alter()
+ */
+function hook_project_issue_json_alter(&$issue_json) {
+  $result = comment_upload_fetch_all($issue_json['id'], FALSE);
+  while ($row = db_fetch_object($result)) {
+    $issue['attachments'][$row->cid]['contributor'] = $row->uid;
+    $issue['attachments'][$row->cid]['comment-id'] = $row->cid;
+    $issue['attachments'][$row->cid]['urls'][$row->fid] = file_create_url($row->filepath);
+  }
+}
diff --git a/project_issue.module b/project_issue.module
index b0e709a..cf2beb2 100644
--- a/project_issue.module
+++ b/project_issue.module
@@ -234,6 +234,15 @@ function project_issue_menu() {
     'type' => MENU_CALLBACK,
   );
 
+  $items['node/%node/project-issue/json'] = array(
+    'title' => 'JSON',
+    'page callback' => 'project_issue_json',
+    'page arguments' => array(1),
+    'access callback' => 'project_issue_menu_access_view',
+    'access arguments' => array(1),
+    'type' => MENU_CALLBACK,
+    'file' => 'includes/project_issue.json.inc',
+  );
   // Autocomplete paths.
 
   // Autocomplete a comma-separated list of projects that have issues enabled.
@@ -300,6 +309,13 @@ function project_issue_menu_access($type) {
   return user_access('access project issues') || user_access('access own project issues');
 }
 
+/**
+ * Menu access callback; Checks whether $node is an issue and the user is allowed to view it.
+ */
+function project_issue_menu_access_view($node) {
+  return $node->type == 'project_issue' && node_access('view', $node);
+}
+
 function project_issue_help($path, $arg) {
   switch ($path) {
     case 'admin/help#project_issue':
