API & webhooks
Feedback doesn't have to come from the widget, and it doesn't have to stay in Voicebox.
Submitting feedback
The same endpoint the widget uses. It takes your publishable key, so it's safe from a browser. Pipe in support tickets, app-store reviews, or anything else you already collect.
curl -X POST https://usevoicebox.dev/api/ingest \
-H "Content-Type: application/json" \
-d '{
"key": "pk_your_project_key",
"body": "The CSV export times out on large ranges.",
"type": "ISSUE",
"rating": 2,
"email": "user@example.com",
"pageUrl": "https://app.example.com/reports",
"metadata": { "plan": "pro", "userId": "usr_8123" }
}'- key
- required, your publishable project key
- body
- required, the feedback text, up to 5,000 characters
- type
- IDEA · ISSUE · PRAISE · QUESTION · OTHER (default OTHER)
- rating
- optional integer, 1 to 5
- optional, never sent to the model
- pageUrl, locale, referrer
- optional context
- metadata
- optional object, never sent to the model
Reading data back
Create a secret key under Settings → Developers, available on Pro and above. It's shown once and stored only as a hash, so keep it somewhere safe. Send it as a bearer token.
| GET | /api/v1/feedbackNewest first. Filter by project_id, status, type, sentiment, since. |
| GET | /api/v1/feedback/:idA single submission with its theme attached. |
| GET | /api/v1/themesOrdered by priority, so the first item is what to work on next. |
| GET | /api/v1/projectsEvery project in your organization, with its widget key. |
curl "https://usevoicebox.dev/api/v1/themes?limit=2" \
-H "Authorization: Bearer sk_your_secret_key"
{
"data": [
{
"id": "cm4x8k2p90001",
"project_id": "cm4x8k1a70000",
"title": "CSV export times out on large ranges",
"description": "Exports of more than six months fail silently.",
"sentiment": "NEGATIVE",
"item_count": 34,
"negative_share": 0.82,
"priority_score": 3.41,
"status": "ACTIVE",
"last_seen_at": "2026-08-11T09:14:22.000Z"
}
],
"has_more": true,
"next_cursor": "cm4x8k2p90001"
}Paging
Pass next_cursor back as ?cursor= to get the following page. Cursors rather than offsets, because feedback arrives while you're paging and an offset would quietly skip rows. limit tops out at 100.
Errors
Every failure is a JSON body with a stable error.code and a message written for a human reading a log.
{
"error": {
"code": "invalid_key",
"message": "That API key is not valid."
}
}Webhooks
Point Voicebox at an HTTPS URL under Settings → Developers and get a signed POST when something happens. Useful for routing angry feedback into Slack the moment it lands, or opening a ticket when a new theme appears.
feedback.createdFeedback arrivesfeedback.analyzedSentiment and summary are readytheme.createdA new theme is identified
Voicebox-Event: feedback.analyzed
Voicebox-Signature: t=1786521600,v1=8f3c…
{
"event": "feedback.analyzed",
"createdAt": "2026-08-11T09:14:22.000Z",
"data": {
"id": "cm4x8k9d10007",
"project_id": "cm4x8k1a70000",
"body": "The CSV export times out on large ranges.",
"type": "ISSUE",
"sentiment": "NEGATIVE",
"sentiment_score": -0.7,
"category": "Data export",
"summary": "Export fails on ranges longer than six months.",
"theme": { "id": "cm4x8k2p90001", "title": "CSV export times out" }
}
}Verifying the signature
The timestamp is inside the signed string, so a captured request can't be replayed at you later. Compare in constant time and reject anything older than five minutes.
import { createHmac, timingSafeEqual } from "node:crypto";
export function verify(header: string, rawBody: string, secret: string) {
const parts = Object.fromEntries(
header.split(",").map((p) => p.split("=")),
);
const age = Math.abs(Date.now() / 1000 - Number(parts.t));
if (age > 300) return false;
const expected = createHmac("sha256", secret)
.update(`${parts.t}.${rawBody}`)
.digest();
const received = Buffer.from(parts.v1, "hex");
return (
expected.length === received.length &&
timingSafeEqual(expected, received)
);
}Retries. A delivery times out after 8 seconds and any non-2xx counts as a failure. We don't replay individual events, but an endpoint that fails twelve times in a row is switched off rather than hammered, and you'll see the last status code in settings. Turn it back on once it's fixed and the counter resets.
Rate limits
Ten submissions per IP per hour per project on ingest. Over-quota accounts keep accepting feedback up to a hard ceiling, we won't discard your users' words because of a billing state, but new items stop being analyzed until the period resets or you upgrade.