JuryTap API, v1

Overview

JuryTap runs live remote mock-jury focus groups: a host pushes question sets to jurors on their own devices and responses stream back. This API exposes the same capabilities as the host console so firm systems and AI agents can:

Base URL https://app.jurytap.com/api/v1
Local dev http://localhost:3000/api/v1
Interactive docs (Scalar) https://app.jurytap.com/api/v1/docs
Machine-readable spec https://app.jurytap.com/api/v1/openapi.json (getOpenApiDocument, unauthenticated)

This API covers the live session and its results. Recruiting, seating, and agreements run through JuryTap's own order flow, which has no public API yet. Participants added here carry an external_id so results reconcile against whatever roster they came from. Jurors have no API: they only ever hold a signed join link.

Authentication and keys

Every request carries a bearer API key:

Authorization: Bearer jt_live_9k2f...   # production
Authorization: Bearer jt_dev_4c8a...    # local development

Keys are org-scoped and are created and revoked by an org admin at https://app.jurytap.com/host/settings/api-keys in the host console, never by the API. The full key is shown once, at creation; only a SHA-256 hash is stored server-side. Give each integration its own key with the narrowest scopes that do its job.

Confirm a key and its scopes with getCurrentPrincipal:

curl -s https://app.jurytap.com/api/v1/me \
  -H "Authorization: Bearer $JT_KEY"
{
  "org": { "id": "b0c9...", "name": "Rivera Trial Group" },
  "key": {
    "id": "2f8e...",
    "name": "In-session agent",
    "scopes": ["sessions:read", "responses:read", "observations:write"],
    "created_at": "2026-09-01T15:04:05Z",
    "last_used_at": "2026-09-05T17:58:12Z"
  }
}

Scopes

Scope Grants
cases:read listCases, getCase
cases:write createCase, updateCase
sessions:read listSessions, getSession, listParticipants, getParticipant, listQuestionSets, getQuestionSet, streamSessionEvents
sessions:write createSession, updateSession, addParticipants, updateParticipant, removeParticipant, createQuestionSet, updateQuestionSet, deleteQuestionSet, addQuestions, updateQuestion, deleteQuestion
sessions:control pushQuestionSet, closeQuestionSet, closeSession
participants:links getJoinLink, revokeJoinLink, and join_url on participant payloads
responses:read listResponses, getQuestionSetSummary, exportSessionCsv, include=responses, answers on stream events
reports:read getSessionReport, getCaseReport, getSessionMetrics, listObservations
observations:write createObservation, deleteObservation
webhooks:manage listWebhooks, createWebhook, updateWebhook, deleteWebhook, listWebhookDeliveries
audit:read listAuditEvents

Ids in another org return 404, never 403, so the API never confirms an id across tenants.

Agent control

Pushing and closing default to the human host. A key with sessions:control may call pushQuestionSet, closeQuestionSet, or closeSession only while the host has switched on Agent control for that session in the control room. Otherwise the call is refused:

{
  "type": "https://app.jurytap.com/problems/agent-control-disabled",
  "title": "Agent control is off for this session",
  "status": 403,
  "detail": "Ask the host to switch on Agent control in the control room, or draft the set for them to push."
}

The session snapshot exposes session.agent_control, so an agent should read the flag first and, when it is false, draft the set and hand it to the host.

Conventions

Errors. RFC 9457 problem documents, Content-Type: application/problem+json. Validation failures add an errors array of {path, message} with a JSON pointer to each offending field. request_id echoes the X-Request-Id header; quote it when reporting a problem.

{
  "type": "https://app.jurytap.com/problems/validation-error",
  "title": "Request body failed validation",
  "status": 422,
  "request_id": "req_7cQ2...",
  "errors": [
    { "path": "/question_sets/0/questions/1/options", "message": "single_choice needs at least 2 options" }
  ]
}

Expect 401 (bad or revoked key), 403 (missing scope or agent control off), 404 (no such resource in your org), 409 (invalid in the resource's current state, for example editing a pushed set), 412 (If-Match mismatch), 422 (validation), and 429 (rate limited).

Idempotency. Every creating POST accepts an Idempotency-Key header (any unique string). Replaying the same key with the same body within 24 hours returns the original response; the same key with a different body is refused with 422.

Pagination. Lists return { "data": [...], "next_cursor": "..." | null }. Pass next_cursor back as ?cursor=; limit is 1 to 200, default 50.

Versions and ETags. The version is in the path (/v1) and changes inside v1 are additive only, so clients must ignore unknown fields. Session responses carry an ETag header (mirrored as session.version) that changes on every state change in the session, juror joins and submissions included. If-Match is required on pushQuestionSet, closeQuestionSet, and closeSession; a stale value is refused with 412 and the caller must re-read before deciding again.

Rate limits. 600 requests per minute per key. Every response carries RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset; a 429 carries Retry-After in seconds.

Quickstart

A full session, start to finish; set JT_KEY first.

1. Create a case (createCase, cases:write):

curl -s -X POST https://app.jurytap.com/api/v1/cases \
  -H "Authorization: Bearer $JT_KEY" -H "Content-Type: application/json" \
  -H "Idempotency-Key: 6f21a0c4-2b5d-4c8e-9f31-0a1b2c3d4e5f" \
  -d '{"title":"Ruiz v. Tom Kat Grain","notes":"Rear-end CMV collision, Taylor County venue. Liability contested."}'

Returns the created Case. Its id, 7c1a9e3f-4b62-4d8a-9f10-2a5b6c7d8e90, is the case id used below.

2. Create the session with jurors and question sets in one call (createSession, sessions:write):

curl -s -X POST https://app.jurytap.com/api/v1/cases/7c1a9e3f-4b62-4d8a-9f10-2a5b6c7d8e90/sessions \
  -H "Authorization: Bearer $JT_KEY" -H "Content-Type: application/json" \
  -d '{
    "title": "Panel A, Tuesday evening",
    "participants": [
      { "display_name": "Pat R.", "external_id": "js_9f2a",
        "demographics": { "county": "Taylor", "age": 41, "gender": "female", "ethnicity": "hispanic_latino",
                          "occupation": "nurse", "education": "bachelor", "political_beliefs": "moderate",
                          "group_identity": "church volunteer", "prior_focus_group": false },
        "screening": [
          { "key": "cmv_experience", "question": "Regular experience with CMVs?", "answer": "No" },
          { "key": "knows_cmv_injured", "question": "Know anyone injured in a CMV wreck?", "answer": "Yes, a coworker" }
        ] },
      { "display_name": "Jordan M.", "external_id": "js_1b77",
        "demographics": { "county": "Taylor", "age": 58, "gender": "male", "education": "high_school",
                          "political_beliefs": "conservative", "prior_focus_group": true },
        "screening": [ { "key": "cmv_experience", "question": "Regular experience with CMVs?", "answer": "Yes, daily" } ] }
    ],
    "question_sets": [
      { "title": "After plaintiff presentation", "questions": [
        { "kind": "likert", "prompt": "The plaintiff'\''s account of the crash was believable.",
          "config": { "scale": 7, "min_label": "Strongly disagree", "max_label": "Strongly agree" } },
        { "kind": "free_text", "prompt": "What one thing stood out to you?" } ] },
      { "title": "Damages", "questions": [
        { "kind": "numeric", "prompt": "If you found for the plaintiff, what amount would you award?",
          "config": { "unit": "$", "min": 0 } } ] }
    ]
  }'

The response is a SessionSnapshot in draft, with an ETag header. Nothing has reached jurors yet.

3. Read the snapshot and its ETag (getSession, sessions:read):

curl -si "https://app.jurytap.com/api/v1/sessions/3e6b21d4-8a77-4c19-b0f2-5d4e3c2b1a09?include=responses,summaries" \
  -H "Authorization: Bearer $JT_KEY"
HTTP/2 200
etag: "17"
{
  "session": { "id": "3e6b21d4-8a77-4c19-b0f2-5d4e3c2b1a09", "case_id": "7c1a9e3f-4b62-4d8a-9f10-2a5b6c7d8e90",
    "title": "Panel A, Tuesday evening", "status": "draft", "agent_control": true,
    "created_at": "2026-09-05T16:12:00Z", "started_at": null, "closed_at": null, "version": "17" },
  "open_question_set_id": null,
  "participants": [ ... ], "question_sets": [ ... ], "metrics": { ... }
}

4. Fetch a join link (getJoinLink, participants:links). The link is a bearer credential for one juror: hand it only to the system that distributes links. Every read is audit-logged.

curl -s https://app.jurytap.com/api/v1/participants/9a1b2c3d-4e5f-4a6b-8c7d-0e1f2a3b4c5d/join-link \
  -H "Authorization: Bearer $JT_KEY"
{ "participant_id": "9a1b2c3d-4e5f-4a6b-8c7d-0e1f2a3b4c5d",
  "url": "https://app.jurytap.com/j/eyJwIjoiOWExYjJjM2Qi...",
  "issued_at": "2026-09-05T16:13:40Z" }

5. Push the first set (pushQuestionSet, sessions:control plus agent control on):

curl -s -X POST https://app.jurytap.com/api/v1/question-sets/b4d2f1a6-3c98-4e57-a1b2-c3d4e5f60718/push \
  -H "Authorization: Bearer $JT_KEY" -H 'If-Match: "17"'

The set opens on every juror device, the session moves to live, and a fresh snapshot comes back with a new ETag.

6. Watch the stream (streamSessionEvents, sessions:read plus responses:read for answers):

curl -N -H "Authorization: Bearer $JT_KEY" -H "Last-Event-ID: 11" \
  https://app.jurytap.com/api/v1/sessions/3e6b21d4-8a77-4c19-b0f2-5d4e3c2b1a09/events
: heartbeat

id: 12
event: participant.joined
data: {"id":12,"type":"participant.joined","session_id":"3e6b21d4-8a77-4c19-b0f2-5d4e3c2b1a09","case_id":"7c1a9e3f-4b62-4d8a-9f10-2a5b6c7d8e90","occurred_at":"2026-09-05T18:02:11Z","actor":{"kind":"juror","id":"9a1b2c3d-4e5f-4a6b-8c7d-0e1f2a3b4c5d","name":"Pat R."},"data":{"participant_id":"9a1b2c3d-4e5f-4a6b-8c7d-0e1f2a3b4c5d"}}

7. Read the set summary (getQuestionSetSummary, responses:read):

curl -s https://app.jurytap.com/api/v1/question-sets/b4d2f1a6-3c98-4e57-a1b2-c3d4e5f60718/summary \
  -H "Authorization: Bearer $JT_KEY"
{ "question_set_id": "b4d2f1a6-3c98-4e57-a1b2-c3d4e5f60718", "status": "open",
  "participant_count": 2, "joined_count": 2,
  "questions": [
    { "question": { "id": "0f3d7b1a-2c5e-4b8c-9a0e-7f2d1c3b4a5e", "question_set_id": "b4d2f1a6-3c98-4e57-a1b2-c3d4e5f60718",
                    "position": 1, "kind": "likert", "prompt": "The plaintiff's account of the crash was believable.",
                    "options": null, "config": { "scale": 7, "min_label": "Strongly disagree", "max_label": "Strongly agree" } },
      "summary": { "kind": "likert", "answered": 2, "scale": 7, "counts": [0,0,1,0,0,1,0], "mean": 4.0 } } ] }

8. Close the session (closeSession, sessions:control plus agent control on). Irreversible: it closes any open set and tells every juror device the session is over.

curl -s -X POST https://app.jurytap.com/api/v1/sessions/3e6b21d4-8a77-4c19-b0f2-5d4e3c2b1a09/close \
  -H "Authorization: Bearer $JT_KEY" -H 'If-Match: "43"'

9. Fetch the report (getSessionReport, reports:read):

curl -s https://app.jurytap.com/api/v1/sessions/3e6b21d4-8a77-4c19-b0f2-5d4e3c2b1a09/report \
  -H "Authorization: Bearer $JT_KEY"

Reading data live

Three options, cheapest first:

  1. getQuestionSetSummary returns the same aggregates the control room draws for one pushed set: choice counts, likert distribution and mean, numeric min, max, mean and median, and attributed free-text entries. Cheap to poll every few seconds while a set is open.
  2. listResponses?since=<RFC 3339> returns only answers submitted after an instant, in long format, one record per answer. Filter further by question_set_id, question_id, or participant_id.
  3. streamSessionEvents is a text/event-stream of everything that happens, in order. Each frame carries a durable per-session sequence id, so a reconnect with Last-Event-ID: <id> (or ?after=<id>) replays what was missed and then goes live. The server sends a comment line as a heartbeat every 15 seconds. The stream ends after the session.closed event.

Event types: session.started, session.closed, set.pushed, set.closed, participant.joined, responses.submitted, observation.created. responses.submitted arrives with its answers array omitted unless the key also has responses:read.

The stream is durable, but the snapshot is the source of truth: call getSession?include=responses,summaries on connect, then tail the stream.

Reading data after the session

Webhooks

Register an endpoint with createWebhook (webhooks:manage). Endpoints must be https. An empty events array means all types.

curl -s -X POST https://app.jurytap.com/api/v1/webhooks \
  -H "Authorization: Bearer $JT_KEY" -H "Content-Type: application/json" \
  -d '{"url":"https://intake.example.com/hooks/jurytap","events":["session.closed","responses.submitted"],"description":"Case memo pipeline"}'

The response includes secret, a whsec_<base64> signing secret shown only once.

Deliveries are POSTs of a SessionEvent document, identical in shape to a stream frame, signed per the Standard Webhooks convention:

webhook-id: msg_2f8e1c...
webhook-timestamp: 1788634931
webhook-signature: v1,K5s0Xq9m...

The signature is a base64 HMAC-SHA256 over ${webhook-id}.${webhook-timestamp}.${raw body}, keyed by the decoded secret. Verify against the raw body, before any JSON parsing:

const crypto = require("node:crypto");

function verify(rawBody, headers, secret) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const header = headers["webhook-signature"];
  if (!id || !timestamp || !header) return false;

  const age = Math.abs(Math.floor(Date.now() / 1000) - Number(timestamp));
  if (!Number.isFinite(age) || age > 300) return false; // 5 minute tolerance

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = crypto.createHmac("sha256", key)
    .update(`${id}.${timestamp}.${rawBody}`)
    .digest();

  return header.split(" ").some((part) => {
    const [version, value] = part.split(",");
    if (version !== "v1" || !value) return false;
    const given = Buffer.from(value, "base64");
    return given.length === expected.length && crypto.timingSafeEqual(given, expected);
  });
}

webhook-signature may list several space-separated versions, so check each one. webhook-id is stable across retries: dedupe on it, because delivery is at least once.

Respond with any 2xx within 10 seconds. Anything else is retried at 1 minute, 5 minutes, 30 minutes, 2 hours, 6 hours, and 24 hours, 7 attempts in total, after which the delivery is marked failed. An endpoint that fails every delivery for 3 days is disabled, enabled goes to false, and disabled_reason says why. listWebhookDeliveries is the debugging log: attempt counts, last status code, and next attempt time.

Participants, demographics, and screening

Participants are jurors: no accounts, one signed join link each, at most 100 per session. external_id must be unique within a session and is how results reconcile with the source roster. A participant added to a live session receives the currently open set when they join. removeParticipant works only before the juror has joined; afterwards use revokeJoinLink so answers are never orphaned.

Demographics are the fields a signup list carries, minus contact details. Every field is optional, stored as columns so reports can group on any of them, and unknown fields are rejected.

Signup field JuryTap field Note
First name, last name display_name Send first name plus last initial; the full name stays with the source roster
County demographics.county Free text, venue proxy
Age demographics.age Integer 18 to 120; reported only in bands 18-24, 25-34, 35-44, 45-54, 55-64, 65+
Gender demographics.gender female, male, nonbinary, other, prefer_not_to_say
Ethnicity demographics.ethnicity white, hispanic_latino, black, asian, native_american, pacific_islander, middle_eastern, multiracial, other, prefer_not_to_say
Occupation demographics.occupation Free text
Education demographics.education less_than_high_school, high_school, some_college, associate, bachelor, graduate, prefer_not_to_say
Political beliefs demographics.political_beliefs very_conservative, conservative, moderate, liberal, very_liberal, prefer_not_to_say
Group identity demographics.group_identity Free text
First time in a focus group? demographics.prior_focus_group Boolean, inverted
Phone, email, employer, full name not accepted Rejected with 422; contact and identity data stays out of the live session
Case-specific screening questions screening[] {key, question, answer}, up to 30 per juror

Use a stable snake_case key per screening question across every session of a case, for example cmv_experience, so the case report can compare panels. Conflict-check answers belong here too: the answer is recorded, the decision to seat the juror stays with the partner.

Observations and citations

An observation is how an attorney-facing agent leaves a mark: a short insight tied to the evidence it came from. createObservation requires at least one citation, and every citation must resolve to a real response, question, or participant in that session. Anything else is rejected with 422. Observations appear in the session report under the author's name and are never shown to jurors.

curl -s -X POST https://app.jurytap.com/api/v1/sessions/3e6b21d4-8a77-4c19-b0f2-5d4e3c2b1a09/observations \
  -H "Authorization: Bearer $JT_KEY" -H "Content-Type: application/json" \
  -d '{
    "body": "Two of eight jurors anchored on the $250k medical-bill figure from the plaintiff'\''s slides; both who awarded under $100k rated the treating physician below 4.",
    "kind": "pattern",
    "question_set_id": "b4d2f1a6-3c98-4e57-a1b2-c3d4e5f60718",
    "citations": [
      { "kind": "response", "question_id": "0f3d7b1a-2c5e-4b8c-9a0e-7f2d1c3b4a5e", "participant_id": "9a1b2c3d-4e5f-4a6b-8c7d-0e1f2a3b4c5d" },
      { "kind": "question", "question_id": "0f3d7b1a-2c5e-4b8c-9a0e-7f2d1c3b4a5e" }
    ]
  }'

kind is one of pattern, outlier, theme, recommendation, note. Setting question_set_id anchors the observation to a checkpoint so the report files it under that set. A key may delete only observations it authored.

Agent playbooks

Setup agent (pre-session, from case notes or a firm intake). Scopes: cases:write, sessions:write, plus participants:links only if it also distributes links.

  1. getCurrentPrincipal to confirm the key and its scopes.
  2. createCase with notes carrying parties, venue, and theory of the case.
  3. createSession with participants and question_sets inline, under an Idempotency-Key.
  4. getJoinLink per participant, if distribution is in scope.
  5. Report the session id and draft set ids to the host: nothing reaches jurors until someone pushes.

In-session attorney-facing agent. Scopes: sessions:read, responses:read, observations:write, plus sessions:control for sessions where the host switches on agent control.

  1. getSession?include=responses,summaries on connect. Keep the ETag and read session.agent_control.
  2. streamSessionEvents, reconnecting with Last-Event-ID after any drop.
  3. getQuestionSetSummary when a set closes or when the aggregate matters.
  4. createObservation for each pattern or outlier worth the report, with citations.
  5. createQuestionSet to draft a follow-up set from what jurors just said.
  6. pushQuestionSet with If-Match when agent_control is on. If it is off, or the call returns 412, re-read and hand the draft to the host.

Post-session analysis and firm-systems agent. Scopes: reports:read, responses:read.

  1. Receive the session.closed webhook, or poll getSession until status is closed.
  2. getSessionReport for metrics, summaries, matrix, jurors, and observations.
  3. listObservations if observations are needed separately from the report.
  4. listResponses or exportSessionCsv for the raw table.
  5. getCaseReport to place this panel against earlier ones, then write the memo into the firm's own system.

Partner sync (JurySignal). Scopes: sessions:write, participants:links, webhooks:manage, plus reports:read if it also pulls results back.

  1. addParticipants with external_id, mapped demographics, and screening answers.
  2. getJoinLink per participant for distribution; revokeJoinLink if a link went to the wrong person.
  3. createWebhook on session.closed.
  4. On delivery, verify the signature, then getSessionReport and reconcile by external_id.

MCP for Claude Code and claude.ai

A hosted MCP endpoint lives at https://app.jurytap.com/mcp over Streamable HTTP. It authenticates today with the same API key as a bearer header; OAuth sign-in for claude.ai custom connectors is planned after the auth migration.

claude mcp add --transport http jurytap https://app.jurytap.com/mcp \
  --header "Authorization: Bearer jt_live_..."

The tools are hand-designed rather than one per endpoint:

Changelog

Version Date Change
v1 2026-09-05 Initial release.