diff --git a/includes/project_issue.json.inc b/includes/project_issue.json.inc
new file mode 100644
index 0000000..941885a
--- /dev/null
+++ b/includes/project_issue.json.inc
@@ -0,0 +1,67 @@
+<?php
+
+/**
+ * Menu callback. Returns JSON for an issue node.
+ *
+ * @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' => $issue->project_issue['rid'],
+      'version' => isset($release->title) ? $release->title : NULL,
+      'assigned-id' => $issue->project_issue['assigned'],
+      'assigned-name' => isset($assigned_user->name) ? $assigned_user->name : NULL,
+      'assigned-url' => isset($assigned_user->name) ? url('user/' . $assigned_user->uid, $options) : NULL,
+      '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, array('absolute' => TRUE)),
+      );
+      $return['comments'][$row->cid] = array(
+        'contributor' => $row->uid,
+        'url' => url('node/' . $return['id'], 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':
