Developer Reference
REST API
Submit voice feedback programmatically from any system — support tickets, chat logs, surveys, CRM tools, app store reviews, or custom integrations. Voices are automatically processed by the s0lve engine: disambiguated, clustered into topics, and ranked by community weight. No UI required.
Version 1.1 · Last updated May 2026
Authentication
All API requests require an API key passed in the Authorization header using the Bearer scheme:
Authorization: Bearer sk_live_your_api_key_here
API keys are created in the s0lve dashboard at Dashboard → API Keys. Each key is scoped to your organization and can only submit voices to boards owned by that organization.
- The full API key is shown only once at creation — copy it immediately
- Keys can be revoked from the dashboard at any time
- Revoked keys return
401 Invalid or revoked API key
Submit a Voice
POST /v1/voices
Submits feedback text to a board. The s0lve engine automatically disambiguates the input (splitting multi-issue submissions into separate voices), classifies sentiment, matches to existing topics or creates new ones, and updates priority scores — all without human intervention.
Request Headers
| Header | Required | Value |
|---|---|---|
Authorization | Yes | Bearer sk_live_... |
Content-Type | Yes | application/json |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
board | string | Yes | Board slug (the URL-friendly name, not the UUID). Must belong to your organization. |
text | string | Yes | The feedback text. Max 10,000 characters. Natural language — the engine handles disambiguation, so multi-issue submissions are fine. |
email | string | No | Submitter’s email. Used for allowlist-mode boards to verify access. Not stored on the voice record (privacy by design). |
sentiment | string | No | "win" or "problem". Overrides the engine’s LLM sentiment classifier. Use when your source has authoritative sentiment. |
zip | string | No | ZIP/postal code. Stored on the voice for geographic analysis. |
context | object | No | Arbitrary metadata object. Stored on the voice and available in admin analytics. E.g. { "source": "zendesk", "ticket_id": "12345" } |
authToken | string | No | JWT token for passthrough-mode boards. The backend verifies this against the board’s configured JWT secret. |
Example Request (cURL)
curl -X POST https://your-backend-url.onrender.com/v1/voices \
-H "Content-Type: application/json" \
-H "Authorization: Bearer sk_live_a1b2c3d4..." \
-d '{
"board": "my-product",
"text": "The checkout flow is confusing and the confirmation email never arrives.",
"sentiment": "problem",
"context": { "source": "support_ticket", "ticket_id": "TK-4521" }
}'Example Request (JavaScript)
const response = await fetch('https://your-backend-url.onrender.com/v1/voices', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer sk_live_a1b2c3d4...'
},
body: JSON.stringify({
board: 'my-product',
text: 'The checkout flow is confusing and the confirmation email never arrives.',
context: { page: window.location.href }
})
});
const data = await response.json();
if (data.ok) {
console.log('Submitted:', data.topicTitle);
}Response
Success (200)
The engine may split a single submission into multiple disambiguated issues. The primary (first) topic is returned at the top level, and all resolved topics are in the topics array.
{
"ok": true,
"board": "my-product",
"voiceId": "a1b2c3d4-...",
"topicId": "e5f6g7h8-...",
"topicTitle": "Checkout flow is confusing",
"topicSummary": "Users report the checkout process is unclear...",
"topicSlug": "checkout-flow-is-confusing",
"topicScore": 12,
"isNewTopic": false,
"voiceCount": 5,
"topics": [ ... ]
}| Field | Type | Description |
|---|---|---|
ok | boolean | true on success |
board | string | Board slug |
voiceId | string | UUID of the primary voice record created |
topicId | string | UUID of the matched or newly created topic |
topicTitle | string | AI-generated topic title |
topicSummary | string | AI-generated topic summary |
topicSlug | string | URL-friendly topic slug |
topicScore | number | Current priority score (community weight) |
isNewTopic | boolean | true if this voice created a new topic |
voiceCount | number | Total voices linked to this topic |
topics | array | All resolved topics when the engine disambiguates multiple issues |
Errors
400 Bad Request
| Error | Cause |
|---|---|
"board and text are required" | Missing board or text in request body |
"Voice text must be a string under 10,000 characters" | text is not a string or exceeds limit |
"Invalid email format" | email provided but not valid (allowlist-mode boards) |
"Harmful content detected" | Engine flagged the submission as harmful |
"Incoherent submission" | Engine could not extract meaningful feedback |
"Off-topic submission" | Submission is not relevant to the board’s context |
401 Unauthorized
| Error | Cause |
|---|---|
"Invalid or revoked API key" | API key is missing, malformed, not found, or revoked |
"Invalid passthrough token" | authToken JWT verification failed |
403 Forbidden
| Error | Cause |
|---|---|
"Email not on access list" | Board uses allowlist mode and the provided email is not authorized |
404 Not Found
| Error | Cause |
|---|---|
"Board not found" | Board slug doesn’t exist or doesn’t belong to your organization |
429 Too Many Requests
| Error | Cause |
|---|---|
"Too many voice submissions. Please try again later." | Rate limit exceeded: 30 per hour per IP |
Rate Limits
| Limit | Value |
|---|---|
| Voice submissions | 30 requests per hour per IP address |
Rate limit headers are included in every response:
RateLimit-Limit— maximum requests allowedRateLimit-Remaining— requests remaining in the current windowRateLimit-Reset— seconds until the rate limit resets
How the Engine Processes Your Submission
- Disambiguation — The engine analyzes the text and splits multi-issue submissions into separate voices. “The checkout is confusing and the app crashes on iOS” becomes two distinct issues.
- Sentiment Classification — Each issue is classified as
"win"(positive) or"problem"(complaint, bug, missing feature). Override with thesentimentfield. - Embedding & Matching — Each issue is embedded as a vector and compared against existing topics. If a semantic match is found, the voice is linked and the topic’s score increases.
- Topic Creation — If no match is found, a new topic is created with an AI-generated title, summary, and category.
- Refinement — When a voice matches an existing topic, the engine asynchronously refines the topic’s title and summary to incorporate the new perspective.
What Can You Send?
The API accepts any text that a human would understand as feedback, opinion, or experience. The broader the range of inputs you connect, the more complete the picture becomes.
Great fits for the API
| Source | Example text | Why it works |
|---|---|---|
| Contact forms | "I can't figure out how to cancel my subscription" | Direct user intent, natural language |
| Support tickets | "Customer reports checkout failing on Safari mobile" | Human-written problem description |
| App store reviews | "Love the new update but battery drain is terrible" | Rich, multi-issue feedback |
| NPS verbatims | "I'd recommend it if onboarding wasn't so confusing" | The open-text field is where real signal lives |
| Chat transcripts | "I've been trying to reset my password for 20 minutes" | Captures frustration in the user's own words |
| Cancellation reasons | "Switching to a competitor because pricing is too complex" | High-signal churn feedback |
| Sales call notes | "Prospect loved the dashboard but needs SSO before they can buy" | Surfaces product blockers from the field |
| Event/kiosk feedback | "The registration line was way too long" | Physical-world feedback |
| Chatbot fallbacks | "The bot couldn't help me find return shipping labels" | Reveals gaps in automated support |
| Internal feedback | "Our deploy process takes 45 minutes and blocks the whole team" | Employee experience insights |
What the engine needs: natural language
The text field should contain language a person would write or say — not raw machine data.
This will work well:
“App crashed when I tried to upload a photo from my camera roll on iOS 17”
This won’t produce meaningful results:
“NullPointerException at com.app.PhotoUploader.process(PhotoUploader.java:243)”
The difference isn’t the source — it’s whether the content is human-readable. Many systems capture both: a stack trace for engineers and a user-facing description for the person who experienced it. Send the human part.
Bridging machine data into s0lve
Use structured data for the context field (metadata) and natural language for the text field (what the engine processes):
{
"board": "my-product",
"text": "Checkout keeps failing when I try to use Apple Pay",
"sentiment": "problem",
"context": {
"source": "error_monitoring",
"error_code": "PAYMENT_TIMEOUT",
"platform": "iOS",
"app_version": "3.2.1",
"error_rate": 0.124
}
}Sentiment Override Guide
When integrating sources that have their own rating system, use the sentiment field to bypass the LLM classifier:
| Source Signal | Recommended sentiment |
|---|---|
| 1-2 star review | "problem" |
| 4-5 star review | "win" |
| 3 star review | Omit (let the engine classify from text) |
| NPS 0-6 (Detractor) | "problem" |
| NPS 9-10 (Promoter) | "win" |
| NPS 7-8 (Passive) | Omit |
| Support ticket | "problem" (usually) |
| Feature request | "problem" (the absence is the issue) |
| Positive testimonial | "win" |
Integration Examples
Python
import requests
response = requests.post(
'https://your-backend-url.onrender.com/v1/voices',
headers={
'Content-Type': 'application/json',
'Authorization': 'Bearer sk_live_a1b2c3d4...'
},
json={
'board': 'my-product',
'text': 'Love the new dashboard redesign!',
'sentiment': 'win',
'context': {'source': 'app_store', 'rating': 5}
}
)
data = response.json()
print(f"Topic: {data['topicTitle']} (score: {data['topicScore']})")Node.js
const response = await fetch('https://your-backend-url.onrender.com/v1/voices', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer sk_live_a1b2c3d4...'
},
body: JSON.stringify({
board: 'my-product',
text: 'The mobile app keeps crashing when I try to upload photos.',
context: { source: 'zendesk', ticket_id: 'ZD-8842' }
})
});
const { ok, topicTitle, isNewTopic, voiceCount } = await response.json();
console.log(`${isNewTopic ? 'New' : 'Existing'} topic: ${topicTitle} (${voiceCount} voices)`);Batch Import
const reviews = [
{ text: 'Great app, love the speed!', rating: 5 },
{ text: 'Crashes every time I open settings', rating: 1 },
{ text: 'Decent but needs dark mode', rating: 3 },
];
for (const review of reviews) {
const sentiment = review.rating >= 4 ? 'win'
: review.rating <= 2 ? 'problem' : undefined;
await fetch('https://your-backend-url.onrender.com/v1/voices', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer sk_live_a1b2c3d4...'
},
body: JSON.stringify({
board: 'my-product',
text: review.text,
...(sentiment && { sentiment }),
context: { source: 'app_store', rating: review.rating }
})
});
// Respect rate limits — space out bulk imports
await new Promise(r => setTimeout(r, 2000));
}s0lve REST API Reference v1.1 · Questions? Reach out at api@s0lve.com