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.
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"
}
}| 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.
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.
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.
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"Three options, cheapest first:
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.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.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.
getSessionReport: the attorney report as data. Metrics, every pushed set with per-question summaries, the juror-by-juror matrix, the juror list with demographics, and every observation. Numbers and quotes are keyed to participant and question ids, which is what lets a synthesis cite its sources. Available while live; the numbers are simply partial.getCaseReport: every question asked to two or more panels of a case, side by side, plus per-session metrics. Questions match by normalized prompt, kind, and scale or unit. Free text is not compared here.getSessionMetrics: duration, joined count, capture rate, completion rate, per-set timing.exportSessionCsv: the long-format CSV the console downloads, one row per answer, formula-injection guarded.listObservations and listAuditEvents: the cited insights, and who did what, filterable by session or case.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 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.
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.
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.
getCurrentPrincipal to confirm the key and its scopes.createCase with notes carrying parties, venue, and theory of the case.createSession with participants and question_sets inline, under an Idempotency-Key.getJoinLink per participant, if distribution is in scope.In-session attorney-facing agent. Scopes: sessions:read, responses:read, observations:write, plus sessions:control for sessions where the host switches on agent control.
getSession?include=responses,summaries on connect. Keep the ETag and read session.agent_control.streamSessionEvents, reconnecting with Last-Event-ID after any drop.getQuestionSetSummary when a set closes or when the aggregate matters.createObservation for each pattern or outlier worth the report, with citations.createQuestionSet to draft a follow-up set from what jurors just said.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.
session.closed webhook, or poll getSession until status is closed.getSessionReport for metrics, summaries, matrix, jurors, and observations.listObservations if observations are needed separately from the report.listResponses or exportSessionCsv for the raw table.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.
addParticipants with external_id, mapped demographics, and screening answers.getJoinLink per participant for distribution; revokeJoinLink if a link went to the wrong person.createWebhook on session.closed.getSessionReport and reconcile by external_id.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:
setup_session: creates a case if needed and a session with jurors and question sets in one step.get_room: returns the session snapshot with per-set summaries, the current state of the room.watch_room: follows the event stream until a condition is met, for example a set closing or every juror answering.draft_follow_up_set: creates a draft question set from what jurors just said, without pushing it.push_next_set: checks agent_control first and otherwise returns a message for the host to push.record_observation: writes a cited insight into the session report.get_report: returns the session or case report as data.| Version | Date | Change |
|---|---|---|
| v1 | 2026-09-05 | Initial release. |