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.

POST /api/ingest
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" }
  }'
Fields
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
email
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/feedback

Newest first. Filter by project_id, status, type, sentiment, since.

GET/api/v1/feedback/:id

A single submission with its theme attached.

GET/api/v1/themes

Ordered by priority, so the first item is what to work on next.

GET/api/v1/projects

Every project in your organization, with its widget key.

GET /api/v1/themes
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.

401 Unauthorized
{
  "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 arrives
  • feedback.analyzedSentiment and summary are ready
  • theme.createdA new theme is identified
POST your-endpoint
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.

verify.ts
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.