Userz

Server-side endpoints

Read feedback + agent runs, list apps, nudge feedback through the lifecycle. Authenticate every call with an sk_ key.

These endpoints are server-only. sk_ keys can read any feedback in the Org and modify lifecycle state, never embed one in browser code or commit it to a repository.

Authentication

Every route here requires an Org API key: Authorization: Bearer sk_…. Mint keys in the dashboard under Settings → API keys. Plaintext is shown once at creation; rotate via the same page.

Per-key rate limit is approximately 600 req/min (token bucket: capacity 100, refill 10/sec). Exceeded calls return 429 rate_limited with Retry-After.

GET /v1/apps

GET/v1/appsAuth: sk_ key

List every App in the authenticated Org.

Response (200)

JSON
{
  "data": [
    {
      "id": "65fa1f3e8a1e5f2d9c1a5c01",
      "slug": "marketing-site",
      "name": "Marketing site",
      "description": "Next.js + Vercel",
      "publicKey": "pub_GLLHdYCLb2A2d9ieY2LmmwRJ",
      "publicSubmissionEnabled": true,
      "processingMode": "auto_pr",
      "batchEnabled": false,
      "batchWindowHours": 24,
      "allowedOrigins": ["https://acme.com"],
      "createdAt": "2026-04-01T08:24:11.000Z"
    }
  ]
}

GET /v1/feedback

GET/v1/feedbackAuth: sk_ key

Cursor-paged list, newest first. All filters are optional.

Query

NameTypeDescription
appId24-char hex idRestrict to one App. Default: all Apps in the Org.
statusstring (FeedbackStatus)Filter on a single status value (e.g. sanitized, pr_opened, archived).
cursor24-char hex idThe id of the last item from the previous page. Pass back as-is to continue.
limitnumber (1–100)Default 50.
includeDuplicatesbooleanDefault false — duplicates are hidden so the list matches the dashboard. Set to true for power-user audits.

Response (200)

JSON
{
  "data": [
    {
      "id": "69e2f2d0ad2f5eef87449d83",
      "appId": "65fa1f3e8a1e5f2d9c1a5c01",
      "status": "pr_opened",
      "submittedBy": { "mode": "private", "externalUserId": "user_abc", "email": "[email protected]" },
      "severity": "high",
      "text": "Cart total shows NaN after applying coupon",
      "url": "https://app.acme.com/checkout",
      "createdAt": "2026-04-18T09:11:12.000Z",
      "processedAt": "2026-04-18T09:13:48.000Z",
      "mergeRequestUrl": "https://github.com/acme/web/pull/4521",
      "duplicateOfId": null,
      "duplicateCount": 2,
      "theme": "Checkout coupon math",
      "consolidatedText": "Several users report incorrect cart totals after applying a coupon …",
      "lastDuplicateAt": "2026-04-18T11:42:01.000Z"
    }
  ],
  "nextCursor": "69e2f2d0ad2f5eef87449d83"
}

duplicateOfId is set on rows that were merged into another report (only visible when includeDuplicates=true). When the row is a lead with attached duplicates, duplicateCount is positive and theme / consolidatedText describe the cluster.

nextCursor is null when the page wasn't full, i.e. you're on the last page.

GET /v1/feedback/:id

GET/v1/feedback/:idAuth: sk_ key

Full feedback row including attachments, consoleLogs, userAgent, viewport, componentTarget, and customer-attached submissionMeta. When the row is a lead with attached duplicates, the response also carries a duplicates[] array (capped at 200 entries) with each duplicate's text, submitter, and timestamp.

Errors

NameTypeDescription
400invalid_idid is not a valid 24-char hex id.
404not_foundNo feedback with that id in your Org (cross-Org reads return 404, not 403).

PATCH /v1/feedback/:id

PATCH/v1/feedback/:idAuth: sk_ key

Customer lifecycle nudge. The full FSM lives server-side; this endpoint exposes two legal moves:

Body

NameTypeDescription
status"queued" | "archived"queued moves a sanitized / awaiting_manual / failed item into the agent queue (manual mode). archived dismisses from any non-terminal-success state.

Response (200)

JSON
{ "id": "69e2f2d0ad2f5eef87449d83", "status": "queued" }

Errors

NameTypeDescription
400invalid_body | invalid_id | no_opBad body, bad id, or empty patch.
404not_foundFeedback id not in this Org.
409invalid_transitionCurrent status doesn't allow the requested move. Response includes from and to so the caller can render an explanation.
409concurrent_update | invalid_targetAnother writer changed the row mid-transition (retry safely), or the target row is a duplicate of another (response includes leadFeedbackId — act on the lead instead).

GET /v1/agent-runs/:id

GET/v1/agent-runs/:idAuth: sk_ key

Per-run audit row. Every run leaves one of these whether it produced a PR or failed.

Response (200)

JSON
{
  "id": "65fb12cda0b8f9e2c0d33aef",
  "appId": "65fa1f3e8a1e5f2d9c1a5c01",
  "feedbackId": "69e2f2d0ad2f5eef87449d83",
  "provider": "claude",
  "model": "claude-sonnet-4-6",
  "status": "succeeded",
  "tokensIn": 18432,
  "tokensOut": 4211,
  "costUsd": 0.231,
  "prUrl": "https://github.com/acme/web/pull/4521",
  "branch": "userz/feedback-69e2f2d0",
  "guardrailReason": null,
  "error": null,
  "timings": { "prepareMs": 812, "cloneMs": 1733, "agentMs": 142091, "pushMs": 644, "prMs": 1209 },
  "startedAt": "2026-04-18T09:11:34.000Z",
  "endedAt":   "2026-04-18T09:13:48.000Z",
  "createdAt": "2026-04-18T09:11:33.000Z"
}

When status is guardrailed, guardrailReason explains why the diff was rejected before the PR opened (e.g. modified .env). When status is failed, error carries the cause.

Typed client

Hand-rolling these calls is fine, but a few lines of typed-client setup pays back fast:

TypeScript
import { createUserzApi } from '@userz-ai/api';

const userz = createUserzApi({ apiKey: process.env.USERZ_API_KEY! });

const apps = await userz.apps.list();
const { data, nextCursor } = await userz.feedback.list({ limit: 50 });
const fb = await userz.feedback.get(data[0].id);
await userz.feedback.update(fb.id, { status: 'queued' });
const run = await userz.agentRuns.get(fb.id);

Full reference at @userz-ai/api.