API & Webhooks
The Consensable API lets you run synthesis queries and receive results programmatically. Webhooks let your server receive a notification each time a synthesis completes.
https://consensable.comAll endpoints require HTTPS. All request and response bodies are JSON unless otherwise noted.
Authentication
API keys are managed from the Developer section of your account page. Keys start with cnsk_ and are shown once at creation — store them securely. Lost keys cannot be recovered; revoke and create a new one instead.
Pass your API key in the Authorization header:
Authorization: Bearer cnsk_your_api_key_here
API access is available on Plus plans and above. Free plan keys are rejected with 403.
Errors
Errors are returned as JSON with an error field describing the problem.
| Status | Meaning |
|---|---|
| 400 | Bad request — missing or invalid parameter |
| 401 | Missing or invalid API key |
| 403 | Plan restriction — upgrade required |
| 404 | Resource not found or not owned by you |
| 409 | Conflict — e.g. plan key limit reached |
| 429 | Rate limit or credit limit reached |
| 500 | Internal server error |
429 responses include a limitType field (hourly, credits, cooldown, or seat_cap) and the relevant limit / used values.
Rate Limits
The same credit and rate limits that apply to the web UI apply to API requests — they are counted together against your account.
| Plan | Monthly credits | Hourly queries | Cooldown |
|---|---|---|---|
| plus / starter | 3,000 | 30 | 5 s |
| pro | 14,400 | Unlimited | 5 s |
| max / team | 67,200 / Unlimited | Unlimited | 5 s |
1 credit = $0.001 of AI cost (rounded up per query). The 5-second per-user cooldown applies regardless of whether the request comes from the web UI or the API.
API Keys
Create a new API key. The raw key is returned once and never stored — copy it immediately.
Request body
| Field | Type | Description |
|---|---|---|
| name | string* | Label for this key, max 64 chars (e.g. "CI pipeline") |
Response — 201
{
"id": "550e8400-e29b-41d4-a716-446655440000",
"name": "CI pipeline",
"key": "cnsk_a1b2c3d4e5f6...", // shown once only
"keyPrefix": "cnsk_a1b2c3",
"createdAt": "2026-03-30T12:00:00.000Z"
}
List all API keys for your account (active and revoked). The raw key is never returned here.
Response — 200
{
"keys": [
{
"id": "550e8400-...",
"name": "CI pipeline",
"keyPrefix": "cnsk_a1b2c3",
"createdAt": "2026-03-30T12:00:00.000Z",
"lastUsedAt": "2026-03-30T14:22:00.000Z",
"revokedAt": null
}
]
}
Revoke an API key. Any in-flight requests using the key will immediately receive 401. Returns 204 No Content.
Synthesise
Run a multi-AI synthesis query. The response is a Server-Sent Events (SSE) stream — set Accept: text/event-stream or simply read the chunked response. Each event is a JSON-encoded data: line.
Request body
| Field | Type | Description |
|---|---|---|
| question | string* | The question or content to synthesise, max 20,000 chars |
| mode | string | ask (default), factcheck, debate, or write. Fact-check, debate, and write require Plus+. |
| debaterIds | string[]* | Model IDs for the debaters — at least 2. See GET /api/models for available IDs. |
| chairId | string* | Model ID for the chair (synthesiser) |
| searchProvider | string | auto (default), brave, perplexity, or none |
| isPrivate | boolean | If true, result is not visible publicly (Plus+ only; ignored for free) |
| caps | object | Override default caps: { maxCredits, maxRounds, maxSecs } |
debaterIds and chairId are ignored — models are assigned automatically. On Plus+, both fields are required.
SSE event stream
Each event has a type field. Parse each data: line as JSON.
| type | Key fields | Description |
|---|---|---|
| step | step, status, label | Progress update. step 0–4; status is running or done |
| search_context | provider, query, results | Web search results injected as context (when search is enabled) |
| model_query | modelId, modelName, status, preview | Per-model query status (querying → done) |
| analysis | consensus, disagreements | Chair's analysis of which claims are agreed vs. disputed |
| debate | claim, round, event, model, response | Per-claim discussion events: round_start, model_response, resolved, capped |
| result | verdict, synthesised_answer, key_claims, confidence_overall | The final synthesised answer |
| result_saved | id, isPrivate, transcriptId | Confirms the result was persisted — id can be used with GET /api/results/:id |
| cost | costUsd, aiCostUsd, searchCostUsd | Final confirmed cost (arrives after result_saved) |
| error | message | Synthesis failed — stream ends after this event |
Minimal example (Node.js)
const response = await fetch('https://consensable.com/api/synthesise', { method: 'POST', headers: { 'Authorization': `Bearer ${apiKey}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ question: 'Is remote work more productive than office work?', mode: 'ask', debaterIds: ['anthropic/claude-3-5-sonnet', 'openai/gpt-4o', 'google/gemini-pro-1.5'], chairId: 'anthropic/claude-3-5-sonnet' }) }) const reader = response.body.getReader() const decoder = new TextDecoder() let buffer = '' while (true) { const { done, value } = await reader.read() if (done) break buffer += decoder.decode(value, { stream: true }) const lines = buffer.split('\n') buffer = lines.pop() // keep incomplete line for (const line of lines) { if (!line.startsWith('data: ')) continue const event = JSON.parse(line.slice(6)) if (event.type === 'result') console.log(event.synthesised_answer) if (event.type === 'result_saved') console.log('Saved as', event.id) if (event.type === 'error') throw new Error(event.message) } }
Available models
Returns the full list of available model IDs and display names. No authentication required.
On Plus / Starter plans, only the 8 curated standard models are available. On Pro and above, the full OpenRouter catalog is accessible — use GET /api/models/search?q=… to search it (requires auth).
Results
Fetch a previously saved result by its ID. Returns your own results regardless of privacy setting. Requires auth.
Fetch a public result. Returns 404 for private results. No authentication required.
Webhook Endpoints
Register an HTTPS URL to receive event notifications. Webhook endpoints are managed from the Developer section of your account page, or via the API below.
Register a new endpoint. The signing secret is returned once — store it securely. If lost, use the rotate-secret endpoint.
Request body
| Field | Type | Description |
|---|---|---|
| url | string* | HTTPS destination URL, max 512 chars |
| name | string* | Label for this endpoint, max 64 chars |
| events | string[]* | Event types to subscribe to — currently only ["synthesis.completed"] |
Response — 201
{
"id": "7f3d9b2a-...",
"name": "My integration",
"url": "https://example.com/hook",
"events": ["synthesis.completed"],
"isActive": true,
"secret": "a1b2c3d4...", // 64-char hex — shown once only
"createdAt": "2026-03-30T12:00:00.000Z"
}
List all webhook endpoints for your account. The secret is never returned here.
Update an endpoint's url, name, events, or isActive. Send only the fields you want to change.
Generate a new signing secret. The old secret stops working immediately. Returns { "secret": "…" }.
Delete an endpoint and all its delivery history. Returns 204 No Content.
Returns the last 50 delivery attempts for an endpoint, newest first.
Delivery response fields
| Field | Type | Description |
|---|---|---|
| id | string | Delivery UUID |
| eventType | string | e.g. synthesis.completed |
| attemptCount | number | Number of delivery attempts so far (max 5) |
| lastHttpStatus | number|null | HTTP status from your server's last response |
| lastError | string|null | Error message if the last attempt failed |
| deliveredAt | string|null | ISO timestamp of successful delivery, or null if not yet delivered |
| createdAt | string | When the delivery was first queued |
Webhook Events
synthesis.completed
Sent after every successful synthesis — whether triggered from the web UI or the API.
{
"id": "d4e5f6a7-...", // unique delivery ID
"event": "synthesis.completed",
"createdAt": "2026-03-30T14:22:00.000Z",
"data": {
"resultId": "9c1b2a3d-...",
"question": "Is remote work more productive?", // truncated to 500 chars
"mode": "ask",
"verdict": "Likely yes, with caveats",
"isPrivate": false,
"createdAt": "2026-03-30T14:22:00.000Z"
}
}
Use data.resultId with GET /api/results/:id (authenticated) or GET /api/public/results/:id (public only) to fetch the full result.
Signing & Verification
Every delivery includes an X-Consensable-Signature header. Verify it to confirm the request came from Consensable and was not tampered with.
Header format
X-Consensable-Signature: t=1743340920,v1=a1b2c3d4e5f6... X-Consensable-Event: synthesis.completed
The signature is HMAC-SHA256 of {timestamp}.{raw_request_body} using your endpoint's signing secret.
Verification — Node.js
const crypto = require('crypto') function verifyWebhook(rawBody, signatureHeader, secret) { // Parse header: "t=1743340920,v1=abc123..." const parts = Object.fromEntries( signatureHeader.split(',').map(p => p.split('=', 2)) ) const timestamp = parts.t const received = parts.v1 if (!timestamp || !received) throw new Error('Invalid signature header') // Reject requests older than 5 minutes const age = Math.abs(Date.now() / 1000 - Number(timestamp)) if (age > 300) throw new Error('Webhook timestamp too old') const expected = crypto .createHmac('sha256', secret) .update(`${timestamp}.${rawBody}`) .digest('hex') if (!crypto.timingSafeEqual( Buffer.from(received, 'hex'), Buffer.from(expected, 'hex') )) throw new Error('Signature mismatch') } // Express example — requires raw body (before express.json() parses it) app.post('/hook', express.raw({ type: 'application/json' }), (req, res) => { try { verifyWebhook( req.body.toString(), req.headers['x-consensable-signature'], process.env.WEBHOOK_SECRET ) } catch (err) { return res.status(400).json({ error: err.message }) } const event = JSON.parse(req.body) console.log('Received:', event.event, event.data.resultId) res.status(200).end() })
timingSafeEqual to prevent timing attacks. You must read the raw request body before any JSON parsing middleware processes it.
Verification — Python
import hashlib, hmac, time def verify_webhook(raw_body: bytes, signature_header: str, secret: str): parts = dict(p.split('=', 1) for p in signature_header.split(',')) timestamp = parts.get('t') received = parts.get('v1') if not timestamp or not received: raise ValueError('Invalid signature header') age = abs(time.time() - int(timestamp)) if age > 300: raise ValueError('Webhook timestamp too old') signed = f"{timestamp}.".encode() + raw_body expected = hmac.new(secret.encode(), signed, hashlib.sha256).hexdigest() if not hmac.compare_digest(received, expected): raise ValueError('Signature mismatch')
Delivery & Retries
Consensable considers a delivery successful when your endpoint returns any 2xx HTTP status within 10 seconds. Anything else (non-2xx, timeout, or connection error) is treated as a failure and retried.
| Attempt | Delay after previous |
|---|---|
| 1 | Immediate (after synthesis completes) |
| 2 | 30 seconds |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
After 5 failed attempts the delivery is marked as permanently failed. The full attempt history is visible in the delivery log for each endpoint.
Make your endpoint idempotent. Use data.id (the delivery UUID) as an idempotency key — the same delivery may be retried and your server may receive it more than once if it returned a non-2xx status even after processing the payload.
Respond quickly — return 200 immediately and process the payload asynchronously if needed. Slow responses that hit the 10-second timeout will be retried.