GroupWisdom API

Send your team's data. Get wisdom back.

POST items to a project → GroupWisdom decides whether there is anything worth saying, and usually there is not → when there is, wisdom is POSTed to your webhook_url and available via GET anytime.

Five-minute start

Four calls from a terminal, ending with the engine's own verdict. Sign in with Google on the account page and your personal key is on the first screen. Put it in GW_KEY and the commands below run as written.

export GW_KEY=gw_your_personal_key

# 1. Create a project
curl -X POST https://testgroupwisdom.com/v1/projects \
  -H "Authorization: Bearer $GW_KEY" -H "Content-Type: application/json" \
  -d '{"name": "First project"}'
# → {"id": "PROJECT_ID", ...}   copy the id into the calls below

# 2. Send it two pieces of finished work, each attributed to whoever did it
curl -X POST https://testgroupwisdom.com/v1/projects/PROJECT_ID/ingest \
  -H "Authorization: Bearer $GW_KEY" -H "Content-Type: application/json" \
  -d '{"items": [
    {"content": "Shipped the onboarding rewrite: nine screens down to four. Completion went from 41% to 68% in the two weeks since.", "contributed_by": "Ada"},
    {"content": "Went through the 22 support tickets from last quarter. Fourteen were people stuck partway through setup.", "contributed_by": "Bo"}
  ]}'
# → 202. The engine runs on its own a few seconds later.

# 3. Ask what it decided, and why (give it about 20 seconds first)
curl "https://testgroupwisdom.com/v1/projects/PROJECT_ID/gate-records?limit=3" \
  -H "Authorization: Bearer $GW_KEY"

# 4. Read whatever it found
curl "https://testgroupwisdom.com/v1/projects/PROJECT_ID/wisdom?format=full" \
  -H "Authorization: Bearer $GW_KEY"

Step 3 is the one to read first. Most batches end in silence with a recorded reason, and that is the design: a question, a status update, or one person restating their own work produces nothing. The two items above were chosen because a fix and the tickets it addressed are two pieces of finished work that bear on each other; the engine may still call the join obvious and say so in the record.

The same four steps in JavaScript, with the SDK:

import GroupWisdom from '@groupwisdom/sdk'
const gw = new GroupWisdom({ apiKey: process.env.GW_KEY })
const project = await gw.createProject('First project')
await gw.ingest(project.id, [
  { content: 'Shipped the onboarding rewrite: nine screens down to four. Completion went from 41% to 68% in the two weeks since.', contributed_by: 'Ada' },
  { content: 'Went through the 22 support tickets from last quarter. Fourteen were people stuck partway through setup.', contributed_by: 'Bo' },
])
await new Promise(r => setTimeout(r, 20_000))
const { data } = await gw.listWisdom(project.id, undefined, { format: 'full' })
console.log(data)   // [] with a reason in the gate records, or the finding

Live demo

Pick a scenario and click Run demo. Real data goes in via the API, the engine runs live, and the right panel traces it.

DATA GOING IN — 4 items from 3 contributors
Engine
GROUPWISDOM ENGINE
Click Run Demo to watch the engine work...

JavaScript / TypeScript SDK

The easiest way to integrate GroupWisdom. Install once, call functions — no HTTP boilerplate required.

npm install @groupwisdom/sdk

Your personal API key is on your account page.

Quick start

import GroupWisdom from '@groupwisdom/sdk'

const gw = new GroupWisdom({ apiKey: process.env.GROUPWISDOM_API_KEY })

// Create a project
const project = await gw.createProject('my-project')

// Send data — attributed to a specific person
await gw.ingest(project.id, {
  title: 'Q3 user research findings',
  content: 'Users in APAC want offline mode...',
  contributed_by: 'Sarah'
})

// Read the wisdom when ready
const wisdom = await gw.listWisdom(project.id)
wisdom.data.forEach(w => console.log(w.title, "—", w.body))

SDK reference

MethodDescription
new GroupWisdom({ apiKey })Create a client. Pass baseUrl to point at a self-hosted instance.
gw.createProject(name, { webhook_url? })Create a new project. Returns a Project object.
gw.listProjects()List all projects you have access to.
gw.getProject(projectId)Get a single project by ID.
gw.updateProject(projectId, { webhook_url })Update a project's webhook URL.
gw.ingest(projectId, item | item[])Send one or more items. Triggers analysis. Each item can include contributed_by.
gw.listItems(projectId)List all items in a project.
gw.listWisdom(projectId, kind?, { format? })Get the project's wisdom. Default returns { id, title, body }. Pass format: "full" to include kind, status, created_at, confidence, do_next, caveat, missing_voice, and stated_in.
gw.listInsights(...)The former name for listWisdom. Still present and returns identical data, so existing code needs no change.
gw.deleteItem(projectId, itemId)Remove one item.
gw.createKey(projectId, name)Issue a project key. The full key is returned once. Personal key required.
gw.listKeys(projectId)List a project's keys, redacted. Personal key required.
gw.revokeKey(projectId, keyId)Revoke a project key at once. Personal key required.

Full TypeScript types are included — your editor will autocomplete every field. Gate records, memory, usage, full analysis, project deletion and feedback are plain HTTP calls for now (each is two lines of fetch, shown in the reference below); the next SDK release wraps them.

Authentication

There are two types of API key:

Key typeFormatAccess
Personal keygw_...All your projects. Shown on your account page the moment you sign in. Use for setup scripts and trusted backends.
Project keygw_proj_...One specific project only. Create via API or dashboard. Use in production apps — if it leaks, revoke it without affecting your account.
Authorization: Bearer gw_proj_your_project_key

Base URL

https://testgroupwisdom.com/v1

All v1 endpoints are under /v1. The existing web app lives at /api and is unchanged.

Errors

All errors return JSON with an error field and a standard HTTP status code.

StatusMeaning
202Accepted — ingest queued or analysis started, not yet processed
400Bad request — a missing required field, or a webhook URL this server will not deliver to
401Missing, invalid or revoked API key
403A project key used outside its project, or for something only a personal key may do (listing projects, managing keys, reading usage)
404Project, item or finding not found, or not yours — the same answer either way
413Request body over 2 MB
429Rate limit, or a full analysis inside its cooldown. Carries Retry-After in seconds
502Test delivery could not reach your webhook URL

Create project

POST/v1/projects
FieldTypeDescription
namestringProject name required
webhook_urlstringURL to receive wisdom callbacks optional
curl -X POST https://testgroupwisdom.com/v1/projects \
  -H "Authorization: Bearer gw_yourkey" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "Q3 User Research",
    "webhook_url": "https://your-server.com/groupwisdom-hook"
  }'
{
  "id": "5d96994a-9164-44bb-891c-caf57eb6760d",
  "name": "Q3 User Research",
  "created_at": "2026-06-26T14:22:00Z",
  "webhook_url": "https://your-server.com/groupwisdom-hook",
  "engine": "claude",
  "counts": { "items": 0, "wisdom": 0, "insights": 0 },
  "webhook_secret": "9f3a1c…48 hex characters…7b2e"
}

webhook_secret is present only when the request set a webhook_url. It is the key you verify signatures with; a project keeps one secret for its life, and the same value comes back from any later PATCH that sets a URL, but no other call returns it. counts.insights is the older name for counts.wisdom, same value. Personal key only: a project key answers 403.

List projects

GET/v1/projects

Returns every project on your account as a plain array, not a paginated object. Personal key only.

curl https://testgroupwisdom.com/v1/projects \
  -H "Authorization: Bearer gw_yourkey"
[
  { "id": "5d96994a-9164-44bb-891c-caf57eb6760d", "name": "Q3 User Research", "created_at": "2026-06-26T14:22:00Z",
    "webhook_url": "https://your-server.com/groupwisdom-hook", "engine": "claude",
    "counts": { "items": 142, "wisdom": 3, "insights": 3 } }
]

Get project

GET/v1/projects/:id

Returns a single project with current item and wisdom counts, in the same shape as above without webhook_secret. Works with the project's own key.

Update project

PATCH/v1/projects/:id

Update project settings. Only fields you send are changed.

FieldTypeDescription
webhook_urlstring | nullSet to null to remove the webhook
enginestringclaude (default) or muse-spark — which model runs analysis for this project, when the server has that model configured. The hosted API runs Claude; a value it cannot honour is stored and Claude runs regardless.
curl -X PATCH https://testgroupwisdom.com/v1/projects/5d96994a-9164-44bb-891c-caf57eb6760d \
  -H "Authorization: Bearer gw_yourkey" \
  -H "Content-Type: application/json" \
  -d '{"engine": "muse-spark"}'

Delete project

DELETE/v1/projects/:id

Removes the project with its items, wisdom, memory and keys. Personal key only; it cannot be undone, and the analysis allowance it used is not returned.

curl -X DELETE https://testgroupwisdom.com/v1/projects/5d96994a-9164-44bb-891c-caf57eb6760d \
  -H "Authorization: Bearer gw_yourkey"
{ "deleted": true, "id": "5d96994a-9164-44bb-891c-caf57eb6760d" }

Ingest items

POST/v1/projects/:id/ingest

Send one item or an array of items. GroupWisdom queues analysis immediately — wisdom is delivered to your webhook_url within seconds and available via GET anytime.

Returns 202 Accepted — analysis is async.

Item fields

FieldTypeDescription
titlestringShort title or headline required*
contentstringFull text, notes, or description
urlstringLink to source
typestringnote · link · file · thought (default: note)
contributed_bystringName of the person who contributed this item. The engine uses it to attribute findings by name — so one member's completed work can be handed directly to another (do_next, missing_voice). Highly recommended.
channelstringWhere this came from: a room, thread, or channel id from your own app (max 100 characters). Changes what a finding may be built from — see How the engine works. Optional.

* At least one of title, content, or url is required per item.

Building a chat integration? Send content alone. Chat messages have no title, and you do not need to invent one — post the message text as content and we derive the title ourselves. A reviewer building against this API synthesized titles from first sentences before noticing the footnote above, so it is worth stating plainly: {"content": "…", "contributed_by": "Sarah"} is a complete item.

Single item

curl -X POST https://testgroupwisdom.com/v1/projects/5d96994a-9164-44bb-891c-caf57eb6760d/ingest \
  -H "Authorization: Bearer gw_yourkey" \
  -H "Content-Type: application/json" \
  -d '{
    "title": "Users keep abandoning checkout on mobile",
    "content": "Support tickets up 30% this month. Affects iOS more than Android.",
    "type": "note",
    "contributed_by": "Sarah"
  }'

Bulk ingest

curl -X POST https://testgroupwisdom.com/v1/projects/5d96994a-9164-44bb-891c-caf57eb6760d/ingest \
  -H "Authorization: Bearer gw_yourkey" \
  -H "Content-Type: application/json" \
  -d '{
    "items": [
      {"title": "Mobile checkout drop-off up 30%", "type": "note"},
      {"title": "Competitor added Apple Pay last week", "type": "note"},
      {"url": "https://example.com/analytics-report", "type": "link"},
      {"title": "Team decision: prioritise mobile payment flow", "type": "thought"}
    ]
  }'
{
  "accepted": 4,
  "items": [
    {"id": "item_001", "title": "Mobile checkout drop-off up 30%", "type": "note"},
    ...
  ],
  "message": "Items queued for analysis. Insights will be POSTed to your webhook_url when ready."
}

List items

GET/v1/projects/:id/items

Returns items in a project, newest first. Paginated — default 50 per page, max 200.

Query paramDefaultDescription
limit50Items per page (max 200)
offset0Number of items to skip
curl "https://testgroupwisdom.com/v1/projects/5d96994a-9164-44bb-891c-caf57eb6760d/items?limit=20&offset=0" \
  -H "Authorization: Bearer gw_proj_yourkey"
{
  "data": [
    {
      "id": "e4f36a38-5081-4e4c-98c2-ab7f943c09c0",
      "group_id": "5d96994a-9164-44bb-891c-caf57eb6760d",
      "member_id": "d208d22e-7089-41be-b3a5-2c263496488a",
      "contributed_by": "Ada",
      "type": "note",
      "title": "Shipped the onboarding rewrite: nine screens down to four. C",
      "content": "Shipped the onboarding rewrite: nine screens down to four. Completion went from 41% to 68% in the two weeks since.",
      "url": "",
      "source": "api",
      "channel": null,
      "created_at": "2026-09-21T05:12:04Z"
    }
  ],
  "total": 142,
  "limit": 20,
  "offset": 0,
  "has_more": true
}

An item sent with content and no title gets the first sixty characters of its content as its title, which is what you see above. contributed_by is the name you sent; source is api for anything that arrived through this API and the adapter's name otherwise.

Delete item

DELETE/v1/projects/:id/items/:itemId

Permanently removes an item from a project. What the engine already established from it stays in memory until the memory is next folded forward.

curl -X DELETE https://testgroupwisdom.com/v1/projects/5d96994a-9164-44bb-891c-caf57eb6760d/items/e4f36a38-5081-4e4c-98c2-ab7f943c09c0 \
  -H "Authorization: Bearer gw_proj_yourkey"
{ "deleted": true, "id": "e4f36a38-5081-4e4c-98c2-ab7f943c09c0" }

Rename a contributor

POST/v1/projects/:id/rename-contributor

Merges everything filed under one contributor name into another, so work sent before you knew someone's real name stops appearing as a second person. Findings attribute by name, which is why this matters.

curl -X POST https://testgroupwisdom.com/v1/projects/5d96994a-9164-44bb-891c-caf57eb6760d/rename-contributor \
  -H "Authorization: Bearer gw_proj_yourkey" -H "Content-Type: application/json" \
  -d '{"from": "npub1q8x…", "to": "Sarah"}'
{ "renamed": true, "members": 1, "memoryUpdated": true }

How the engine works

Analysis runs automatically a few seconds after each /ingest. Most of the time it produces nothing, and that is the intended result.

Every batch passes through a series of checks before anything is written. The first ones ask whether there is anything here worth thinking about at all: a question to a colleague, a status ping, or a message that restates what the project already knows does not go any further. Only what survives is drafted, and a final review decides whether the draft is strong enough to keep, sharpens the wording, and attaches confidence, do_next, caveat and missing_voice. Weak candidates are dropped rather than softened.

The practical effect is that the great majority of ingested messages never reach the drafting stage, so they cost a fraction of a full analysis.

After it speaks, it pauses

Once a finding is posted, the project stays quiet for a short cooldown regardless of what arrives in the meantime: two minutes on the hosted API at the moment, ten by default when you self-host. Two good findings a minute apart still read as one wall of text in a chat client, so the cooldown protects the thing restraint is for.

This is the most common reason a working integration looks broken: you send more items, nothing comes back, and the engine appears to have stopped. It has not. GET /gate-records will show Spoke within the last 2 minutes. against the batch. Set GW_WISDOM_COOLDOWN_MIN if you are self-hosting and want a different window.

Cost does not grow with your project

The engine reasons from a compact working memory of what your group has established, not from your accumulated history. That memory is bounded and folded forward as new items arrive, so a scan on a project's five hundredth day costs about what it cost on the first. Adding history makes the findings better without making the analysis more expensive.

POST /v1/projects/:id/analyze is the exception, and the reason it exists as a separate call: it re-reads the project in full. Use it deliberately rather than on a schedule.

Why it stayed quiet

Silence is a decision, and every one is recorded with its reason. If you are wondering why a message you expected to produce something did not, you can read the record rather than guess — see Why it stayed quiet.

What a finding is allowed to draw on

If you send a channel with your items, findings become channel-aware: what the engine says in one place is built only from what that place can see, while its understanding of the project as a whole still spans everything you send. Anything it can trace to a different channel stays there. Leave channel off and the project is treated as one audience, which is the right choice when everyone sees everything.

Rate limits and allowance

Two separate things bound what you can do: how fast you may call the API, and how much analysis your account may run.

Rate limits

Counted per API key, in a rolling one-minute window. Exceeding one returns 429 with a JSON body, never an HTML page.

RoutesRequests per minute
/v1/* — everything in this reference240
Sign-in and account routes10
Connecting a Buzz community5
POST /v1/demo — a project and key with no account5 per hour

Ingest accepts arrays, so a batch of items is one request. Sending them one at a time is the usual way to reach the limit for no benefit.

Analysis allowance

Analysis costs real money to run, so every account has an allowance covering all of its projects together. Deleting a project does not return any of it.

Check where you stand with GET /v1/usage, which returns percent_used and limit_reached. Nothing about it is silent: when the allowance is spent, /ingest keeps accepting and storing your items and the engine stops analysing them until it resets, so no data is lost and nothing fails unexpectedly.

Published plans and per-item pricing are not settled yet. If you are building something that needs a specific ceiling, get in touch before you depend on the current one.

What it has understood

GET/v1/projects/:id/memory

Returns the working memory the engine reasons from: the project's purpose as it understands it, the facts it has established and who contributed them, the decisions it has seen made, and the questions it still considers open. Each fact carries the items it came from.

This is the fastest way to answer "is it actually following what we are doing". If a finding looks wrong, this shows you what it was built on. It is also the first place to look when the engine has gone quiet and you expected otherwise.

The response is always { memory, updated_at }. On a project the engine has not read yet, both are null; treat that as the normal state of a new project, not an error.

curl "https://testgroupwisdom.com/v1/projects/5d96994a-9164-44bb-891c-caf57eb6760d/memory" \
  -H "Authorization: Bearer gw_proj_yourkey"
// A project nothing has been sent to yet
{ "memory": null, "updated_at": null }

// Once the engine has read something
{
  "memory": {
    "purpose": "Shipping the mobile checkout redesign",
    "facts": [
      { "fact": "The redesign for the mobile checkout tested 22% faster than the current flow", "by": "Sarah", "sources": ["a1b2c3d4"] }
    ],
    "decisions": [
      { "decision": "Ship the redesign behind a flag", "sources": ["e5f6a7b8"] }
    ],
    "open_questions": ["Who owns the migration for saved cards?"],
    "active_wisdom": [
      { "id": "9f2c1e04-7b3a-4d51-9c88-1a6e2f0b47d3", "kind": "opportunity", "title": "Saved-card migration is the last blocker" }
    ]
  },
  "updated_at": "2026-09-21T05:12:36Z"
}
FieldDescription
purposeWhat the engine understands the project to be for, in its own words.
facts[]fact, by (the contributor's name as sent in contributed_by), and sources: the first eight characters of the item ids it came from. Each fact names the thing it is about, so a group working on two things is not confused for one.
decisions[]decision and sources, only for things the group has settled in so many words.
open_questions[]Questions the engine considers still open, as strings.
active_wisdom[]The findings currently standing: id, kind, title. Maintained by the engine so it does not say the same thing twice; a dismissed finding drops out.

Why it stayed quiet

GET/v1/projects/:id/gate-records

The engine says nothing most of the time, on purpose. Every one of those decisions is recorded with the reason for it, so silence is auditable rather than mysterious. Supports ?limit (default 50, max 200), newest first.

If your integration seems too quiet, read this before assuming it is broken. Most of the time it will tell you the messages were requests, restatements, or a single person thinking aloud, none of which the engine treats as something to combine.

FieldValues
stagescan (the pass over a new batch), review (the check on a drafted finding), memory (the working memory being folded forward), handoff (finished work offered to someone as they start a task)
verdictsilent nothing was worth saying · spoken a finding was surfaced, with its insight_id · suppressed something was drafted and the review dropped it · dropped a fact left the working memory in the last update, named in reason · error a stage failed, with the message
kindThe finding's kind on spoken and suppressed records; null otherwise
titleThe finding's headline where there was one; null otherwise
reasonThe engine's reason in full, written from your messages, so it can quote them
insight_idThe finding's id on spoken records; null otherwise
id, group_id, created_atThe record's own id, the project, and when

A spoken finding usually has more than one review record: one for the verdict, and one for each thing the review noticed about it, such as a quote it could not find in the messages or a figure that appears in no contribution. Read them together.

curl "https://testgroupwisdom.com/v1/projects/5d96994a-9164-44bb-891c-caf57eb6760d/gate-records?limit=20" \
  -H "Authorization: Bearer gw_proj_yourkey"
{
  "records": [
    { "id": "de8e196c-d27a-4b9b-b82d-a53b1f57f007", "group_id": "5d96994a-9164-44bb-891c-caf57eb6760d",
      "stage": "scan", "verdict": "silent", "kind": null, "title": null, "insight_id": null,
      "reason": "Test 8: the join is obvious from the two pieces. A reader holding both contributions would already think the rewrite addressed what most of the tickets were about.",
      "created_at": "2026-09-21T05:12:36Z" },
    { "id": "…", "group_id": "…",
      "stage": "memory", "verdict": "dropped", "kind": null, "title": null, "insight_id": null,
      "reason": "No longer carried forward after the last update: Free trial period under consideration, either 3 days or 7 days",
      "created_at": "2026-09-21T05:12:35Z" },
    { "id": "…", "group_id": "…",
      "stage": "review", "verdict": "spoken", "kind": "opportunity", "title": "Card-entry friction is blocking mobile checkout",
      "insight_id": "03807d77-c0b6-4e5d-b855-d33eb0939dac",
      "reason": "confidence medium; joined to 2 piece(s) of memory, named by id",
      "created_at": "2026-09-21T05:14:28Z" },
    { "id": "…", "group_id": "…",
      "stage": "review", "verdict": "suppressed", "kind": "tension", "title": "Team is aligned on timing", "insight_id": null,
      "reason": "confidence low",
      "created_at": "2026-09-21T05:14:28Z" }
  ]
}

Usage

GET/v1/usage

How much of your analysis allowance you have used, across every project on your account. Returns percent_used and limit_reached. When the limit is reached, ingest still accepts your items and the engine stops analysing them until the allowance resets, so nothing is lost.

Requires your personal API key. Project keys cannot read account-wide usage.

curl "https://testgroupwisdom.com/v1/usage" \
  -H "Authorization: Bearer gw_yourkey"
{ "percent_used": 12, "limit_reached": false }

List wisdom

GET/v1/projects/:id/wisdom

Returns the wisdom the engine has surfaced, newest first. Supports ?limit, ?offset, and ?kind filters.

GET /v1/projects/:id/insights is the former name for this endpoint and still works, identically. Nothing you have running needs to change.

By default returns a minimal shape — id, title, and body. Pass ?format=full to include the review fields: kind, status, created_at, confidence, do_next, caveat, missing_voice, stated_in, and channel (the channel the finding was drawn for, when your items carry one; otherwise null). status is new, acknowledged or dismissed; findings from live sources arrive as acknowledged.

stated_in is the words a finding rests on, copied from the message that carries them. It is there so a finding can be checked rather than taken on trust: if the quote is not found in what the group actually wrote, the engine lowers its own confidence in the finding and records that it did.

# Default (minimal)
curl "https://testgroupwisdom.com/v1/projects/5d96994a-9164-44bb-891c-caf57eb6760d/wisdom" \
  -H "Authorization: Bearer gw_proj_yourkey"

# Full — includes the review fields
curl "https://testgroupwisdom.com/v1/projects/5d96994a-9164-44bb-891c-caf57eb6760d/wisdom?format=full" \
  -H "Authorization: Bearer gw_proj_yourkey"
// Default response
{
  "data": [
    {
      "id": "9f2c1e04-7b3a-4d51-9c88-1a6e2f0b47d3",
      "title": "Mobile payment friction is a recurring theme",
      "body": "Four of six items point to the same checkout drop-off."
    }
  ],
  "total": 3,
  "limit": 50,
  "offset": 0,
  "has_more": false
}

// With ?format=full
{
  "data": [
    {
      "id": "9f2c1e04-7b3a-4d51-9c88-1a6e2f0b47d3",
      "kind": "pattern",
      "title": "Mobile payment friction is a recurring theme",
      "body": "Four of six items point to the same checkout drop-off.",
      "status": "new",
      "created_at": "2026-07-15T14:30:00Z",
      "confidence": "high",
      "do_next": "Priya's checkout usability study already located the drop-off at the card-entry step.",
      "caveat": "All data is from support tickets — may overrepresent frustrated users.",
      "missing_voice": "Priya",
      "stated_in": "Four of six items point to the same checkout drop-off",
      "channel": null
    }
  ],
  ...
}

Review fields

FieldValuesDescription
confidencehigh · medium · lowHow far the finding can be checked against what was written. high: a contribution states it in so many words and stated_in quotes them; the quote is verified against the real messages. medium: it follows from two contributions but nobody said it outright, which is what most joins are. low: one contribution, a longer inference, or a quote that could not be found in the messages.
do_nextstring or nullInherited work, never a task: one more completed result from another member that the reader now has for free. Usually null, by design — the body already hands over the work, and this only fills when a second member's finished result also applies. If what would go here is something the reader ought to go and do, the field stays null rather than turning into advice.
caveatstring or nullA condition or limitation that could invalidate the finding if ignored.
missing_voicestring or nullThe name of a member whose existing work would strengthen this reader's — just the name, checked against your project's real members. A name we cannot match to the roster is dropped rather than returned, so this field never invents a person.

These fields are always present in format=full responses — they may be null when not applicable, and do_next and missing_voice usually are. That is the intended behaviour rather than a gap: both exist to hand over a second member's finished work, and most findings have only the one the body already carries.

You decide what to show your users. A simple card might render only title and body. A richer view might surface do_next as the inherited finding a member can build on. A team dashboard might show all fields including confidence and missing voices.

Trigger analysis

POST/v1/projects/:id/analyze

Triggers a full re-analysis of the project immediately, bypassing the automatic debounce timer. Useful when you want fresh wisdom on demand — for example, after a batch ingest or before showing results to a user.

Returns 202 Accepted immediately. Analysis runs in the background; poll List wisdom or wait for your webhook to receive results.

curl -X POST https://testgroupwisdom.com/v1/projects/5d96994a-9164-44bb-891c-caf57eb6760d/analyze \
  -H "Authorization: Bearer gw_proj_yourkey"
{ "message": "Analysis started." }

Wisdom kinds

The engine surfaces wisdom in seven kinds. Six describe what a group is building, never what it is failing to do. The seventh is different in kind: it is addressed to one person, at the moment they announce work, and carries another member's finished result rather than the engine's own conclusion.

KindDescription
convergenceTwo people reached the same finding from different directions. It names both, and what each of them proved.
opportunitySomething the group's own work is already pointing at, that nobody has picked up yet.
tensionTwo views worth putting together to reach a stronger conclusion, stated as the actual difference.
patternA theme running across several contributions that none of them called a theme.
directionThe natural next question the group's collective work is building toward.
decisionSomething the group has arrived at together, and what led there.
handoffSomeone announced work they are about to do, and the group already held finished work by someone else that bears on it: a number, a result, a decision, with the name of who produced it. Composed only from facts already in memory, never inferred. Silent unless the fact is specific, sourced, and not the announcer's own.

Feedback on a finding

POST/v1/wisdom/:id/feedback

Record what a reader made of a finding: helpful, wrong, or late. This is the signal that says whether the engine is any good, as opposed to merely quiet, and the chat adapters send it from reactions. Pass member for who said so and, if your source has one, source_event_id so the verdict can be withdrawn later when the reaction is removed.

curl -X POST https://testgroupwisdom.com/v1/wisdom/9f2c1e04-7b3a-4d51-9c88-1a6e2f0b47d3/feedback \
  -H "Authorization: Bearer gw_proj_yourkey" -H "Content-Type: application/json" \
  -d '{"verdict": "helpful", "member": "Sarah", "source_event_id": "reaction-8841"}'
{ "recorded": true, "verdict": "helpful" }

Withdraw a verdict by its source_event_id with DELETE /v1/wisdom/feedback/:sourceEventId, which answers { "withdrawn": true } (false if nothing was recorded under that id). A finding you do not own answers 404, the same as one that does not exist.

GET/v1/projects/:id/feedback

A project's verdicts and their totals. ?limit caps the rows (default 100, max 500).

{
  "summary": {
    "total": 7,
    "byVerdict": { "helpful": 5, "wrong": 1, "late": 1 },
    "highConfidenceWrong": 0,
    "breakdown": [ { "verdict": "helpful", "confidence": "medium", "n": 5 }, { "verdict": "wrong", "confidence": "low", "n": 1 }, { "verdict": "late", "confidence": "medium", "n": 1 } ]
  },
  "feedback": [
    { "id": "…", "group_id": "…", "insight_id": "9f2c1e04-7b3a-4d51-9c88-1a6e2f0b47d3", "member": "Sarah",
      "verdict": "helpful", "source_event_id": "reaction-8841", "withdrawn": 0, "created_at": "2026-09-21T05:20:11Z" }
  ]
}

Project API keys

Project keys (gw_proj_...) are scoped to a single project. Use them in production apps instead of your personal key — if a project key leaks, you revoke just that key without touching your account or other projects.

Project keys can ingest data, read items, and read wisdom. They cannot create new projects or manage other keys — that requires your personal key.

Create a project key

POST/v1/projects/:id/keys

The full key is only returned once on creation. Store it immediately — subsequent GET /keys calls only show a redacted preview.

FieldTypeDescription
namestringLabel for this key (e.g. "Production", "Staging") required
curl -X POST https://testgroupwisdom.com/v1/projects/5d96994a-9164-44bb-891c-caf57eb6760d/keys \
  -H "Authorization: Bearer gw_your_personal_key" \
  -H "Content-Type: application/json" \
  -d '{"name": "Production"}'
{
  "id": "3c1e7a90-52d4-4b1f-9e2a-7f0c1d8b6e44",
  "name": "Production",
  "key": "gw_proj_a1b2c3d4e5f6...",
  "created_at": "2026-06-30T10:00:00Z"
}

List keys

GET/v1/projects/:id/keys

The project's keys with a redacted preview each, newest first. Personal key only.

curl https://testgroupwisdom.com/v1/projects/5d96994a-9164-44bb-891c-caf57eb6760d/keys \
  -H "Authorization: Bearer gw_your_personal_key"
[
  { "id": "3c1e7a90-52d4-4b1f-9e2a-7f0c1d8b6e44", "name": "Production", "key_preview": "gw_proj_a1b2...",
    "created_at": "2026-06-30T10:00:00Z", "last_used_at": "2026-09-21T05:14:28Z" }
]

Revoke a key

DELETE/v1/projects/:id/keys/:keyId

Immediately invalidates the key. Any request using it afterwards receives 401. Personal key only.

curl -X DELETE https://testgroupwisdom.com/v1/projects/5d96994a-9164-44bb-891c-caf57eb6760d/keys/3c1e7a90-52d4-4b1f-9e2a-7f0c1d8b6e44 \
  -H "Authorization: Bearer gw_your_personal_key"
{ "revoked": true, "id": "3c1e7a90-52d4-4b1f-9e2a-7f0c1d8b6e44" }

Webhook setup

Set a webhook_url on any project (at creation or via PATCH). GroupWisdom will POST to that URL each time new wisdom is generated — typically 10 to 40 seconds after the ingest call (a three-second batch window, then the engine's run).

Your endpoint must return a 2xx status. Failed deliveries are retried twice — after 5 seconds, then 30 seconds. If all three attempts fail, the delivery is dropped — poll /v1/projects/:id/wisdom as a long-term fallback.

Use webhook.site to inspect payloads during development.

Webhook payload

Every webhook request includes an X-GroupWisdom-Signature header — a HMAC-SHA256 signature of the raw body using your webhook_secret. Always verify this before trusting the payload.

// Node.js — verify webhook signature
import { createHmac } from "crypto"

function verifyWebhook(rawBody, signature, secret) {
  const expected = "sha256=" + createHmac("sha256", secret).update(rawBody).digest("hex")
  return expected === signature
}

app.post("/groupwisdom-hook", express.raw({ type: "application/json" }), (req, res) => {
  const sig = req.headers["x-groupwisdom-signature"]
  if (!verifyWebhook(req.body, sig, process.env.GW_WEBHOOK_SECRET)) {
    return res.status(401).send("Invalid signature")
  }
  const payload = JSON.parse(req.body)
  // handle payload.insights ...
  res.sendStatus(200)
})

Your webhook_secret is returned when you set a webhook_url via PATCH. Store it securely: no call other than setting a webhook_url returns it.

Payload shape

Webhook payloads include the full wisdom shape, equivalent to ?format=full, so you have every review field without a follow-up GET.

If you built against the old names, nothing has changed for you. The payload still carries an insights array with identical contents alongside wisdom, and event is still insights.created. Existing receivers keep working untouched; use wisdom for anything new.

POST https://your-server.com/groupwisdom-hook
Content-Type: application/json
X-GroupWisdom-Signature: sha256=abc123...

{
  "event": "insights.created",
  "group_id": "5d96994a-9164-44bb-891c-caf57eb6760d",
  "wisdom": [
    {
      "id": "9f2c1e04-7b3a-4d51-9c88-1a6e2f0b47d3",
      "kind": "pattern",
      "title": "Mobile payment friction is a recurring theme",
      "body": "Four of six items point to the same checkout drop-off.",
      "status": "new",
      "created_at": "2026-07-15T14:30:00Z",
      "confidence": "high",
      "do_next": "Priya's checkout usability study already located the drop-off at the card-entry step.",
      "caveat": null,
      "missing_voice": "Priya",
      "stated_in": "Four of six items point to the same checkout drop-off",
      "channel": null
    }
  ]
}

Test delivery

POST/v1/projects/:id/test-webhook

Sends a signed sample event to the project's webhook_url right now and reports the status your endpoint returned, so you can check the signature code before any real finding arrives. The sample carries "event": "test".

curl -X POST https://testgroupwisdom.com/v1/projects/5d96994a-9164-44bb-891c-caf57eb6760d/test-webhook \
  -H "Authorization: Bearer gw_proj_yourkey"
{ "sent": true, "status": 200 }

A project with no webhook_url answers 400. A URL that resolves to a private network answers 400 as well and nothing is sent; one that cannot be reached answers 502 with the error.

JavaScript / TypeScript example

Using the SDK (recommended):

import GroupWisdom from '@groupwisdom/sdk'

const gw = new GroupWisdom({ apiKey: process.env.GROUPWISDOM_API_KEY })

// Send items from multiple contributors
await gw.ingest("5d96994a-9164-44bb-891c-caf57eb6760d", [
  { title: "User retention dropped 12% in APAC", type: "note", contributed_by: "Sarah" },
  { title: "APAC pricing change rolled out last week", type: "note", contributed_by: "James" },
])

// Poll for wisdom — or set a webhook_url to receive it automatically
const wisdom = await gw.listWisdom("5d96994a-9164-44bb-891c-caf57eb6760d")
wisdom.data.forEach(w => console.log(w.title, "—", w.body))

Using raw fetch (if you prefer no dependency):

const GW_KEY = process.env.GROUPWISDOM_API_KEY;
const BASE   = "https://testgroupwisdom.com/v1";

await fetch(`${BASE}/projects/5d96994a-9164-44bb-891c-caf57eb6760d/ingest`, {
  method: "POST",
  headers: { "Authorization": `Bearer ${GW_KEY}`, "Content-Type": "application/json" },
  body: JSON.stringify({ items: [
    { title: "User retention dropped 12% in APAC", type: "note", contributed_by: "Sarah" },
  ]}),
});

Python example

import os, time, requests

GW_KEY = os.environ["GROUPWISDOM_API_KEY"]
BASE   = "https://testgroupwisdom.com/v1"
HEADERS = {"Authorization": f"Bearer {GW_KEY}", "Content-Type": "application/json"}

def ingest(project_id, items):
    r = requests.post(f"{BASE}/projects/{project_id}/ingest",
                      json={"items": items}, headers=HEADERS)
    r.raise_for_status()
    return r.json()

def list_wisdom(project_id, kind=None):
    params = {"format": "full", **({"kind": kind} if kind else {})}
    r = requests.get(f"{BASE}/projects/{project_id}/wisdom",
                     params=params, headers=HEADERS)
    r.raise_for_status()
    return r.json()["data"]          # paginated: data, total, limit, offset, has_more

def why_quiet(project_id, limit=5):
    r = requests.get(f"{BASE}/projects/{project_id}/gate-records",
                     params={"limit": limit}, headers=HEADERS)
    r.raise_for_status()
    return r.json()["records"]

# Usage
ingest("5d96994a-9164-44bb-891c-caf57eb6760d", [
    {"content": "User retention dropped 12% in APAC this quarter.", "contributed_by": "Sarah"},
    {"content": "The APAC pricing change rolled out last week.", "contributed_by": "James"},
])

time.sleep(20)                       # the engine runs a few seconds after ingest
for w in list_wisdom("5d96994a-9164-44bb-891c-caf57eb6760d"):
    print(f"[{w['kind']}/{w['confidence']}] {w['title']}: {w['body']}")
for rec in why_quiet("5d96994a-9164-44bb-891c-caf57eb6760d"):
    print(rec["stage"], rec["verdict"], rec.get("reason"))