← Back to app

API & Webhooks

Programmatic access — Plus plan and above

The Consensable API lets you run synthesis queries and receive results programmatically. Webhooks let your server receive a notification each time a synthesis completes.

Base URL: https://consensable.com
All 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.

StatusMeaning
400Bad request — missing or invalid parameter
401Missing or invalid API key
403Plan restriction — upgrade required
404Resource not found or not owned by you
409Conflict — e.g. plan key limit reached
429Rate limit or credit limit reached
500Internal 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.

PlanMonthly creditsHourly queriesCooldown
plus / starter3,000305 s
pro14,400Unlimited5 s
max / team67,200 / UnlimitedUnlimited5 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

POST/api/keys

Create a new API key. The raw key is returned once and never stored — copy it immediately.

Request body

FieldTypeDescription
namestring*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"
}
GET/api/keys

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
    }
  ]
}
DELETE/api/keys/:id

Revoke an API key. Any in-flight requests using the key will immediately receive 401. Returns 204 No Content.

Synthesise

POST/api/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

FieldTypeDescription
questionstring*The question or content to synthesise, max 20,000 chars
modestringask (default), factcheck, debate, or write. Fact-check, debate, and write require Plus+.
debaterIdsstring[]*Model IDs for the debaters — at least 2. See GET /api/models for available IDs.
chairIdstring*Model ID for the chair (synthesiser)
searchProviderstringauto (default), brave, perplexity, or none
isPrivatebooleanIf true, result is not visible publicly (Plus+ only; ignored for free)
capsobjectOverride default caps: { maxCredits, maxRounds, maxSecs }
Free plan / auto-tier: 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.

typeKey fieldsDescription
stepstep, status, labelProgress update. step 0–4; status is running or done
search_contextprovider, query, resultsWeb search results injected as context (when search is enabled)
model_querymodelId, modelName, status, previewPer-model query status (queryingdone)
analysisconsensus, disagreementsChair's analysis of which claims are agreed vs. disputed
debateclaim, round, event, model, responsePer-claim discussion events: round_start, model_response, resolved, capped
resultverdict, synthesised_answer, key_claims, confidence_overallThe final synthesised answer
result_savedid, isPrivate, transcriptIdConfirms the result was persisted — id can be used with GET /api/results/:id
costcostUsd, aiCostUsd, searchCostUsdFinal confirmed cost (arrives after result_saved)
errormessageSynthesis 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

GET/api/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

GET/api/results/:id

Fetch a previously saved result by its ID. Returns your own results regardless of privacy setting. Requires auth.

GET/api/public/results/:id

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.

POST/api/webhook-endpoints

Register a new endpoint. The signing secret is returned once — store it securely. If lost, use the rotate-secret endpoint.

Request body

FieldTypeDescription
urlstring*HTTPS destination URL, max 512 chars
namestring*Label for this endpoint, max 64 chars
eventsstring[]*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"
}
GET/api/webhook-endpoints

List all webhook endpoints for your account. The secret is never returned here.

PATCH/api/webhook-endpoints/:id

Update an endpoint's url, name, events, or isActive. Send only the fields you want to change.

POST/api/webhook-endpoints/:id/rotate-secret

Generate a new signing secret. The old secret stops working immediately. Returns { "secret": "…" }.

DELETE/api/webhook-endpoints/:id

Delete an endpoint and all its delivery history. Returns 204 No Content.

GET/api/webhook-endpoints/:id/deliveries

Returns the last 50 delivery attempts for an endpoint, newest first.

Delivery response fields

FieldTypeDescription
idstringDelivery UUID
eventTypestringe.g. synthesis.completed
attemptCountnumberNumber of delivery attempts so far (max 5)
lastHttpStatusnumber|nullHTTP status from your server's last response
lastErrorstring|nullError message if the last attempt failed
deliveredAtstring|nullISO timestamp of successful delivery, or null if not yet delivered
createdAtstringWhen 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()
})
Important: Always verify the signature before trusting the payload. Use 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.

AttemptDelay after previous
1Immediate (after synthesis completes)
230 seconds
35 minutes
430 minutes
52 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.