Server-side endpoints
Read feedback + agent runs, list apps, nudge feedback through the lifecycle. Authenticate every call with an sk_ key.
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
/v1/appsAuth: sk_ keyList every App in the authenticated Org.
Response (200)
{
"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
/v1/feedbackAuth: sk_ keyCursor-paged list, newest first. All filters are optional.
Query
| Name | Type | Description |
|---|---|---|
| appId | 24-char hex id | Restrict to one App. Default: all Apps in the Org. |
| status | string (FeedbackStatus) | Filter on a single status value (e.g. sanitized, pr_opened, archived). |
| cursor | 24-char hex id | The id of the last item from the previous page. Pass back as-is to continue. |
| limit | number (1–100) | Default 50. |
| includeDuplicates | boolean | Default false — duplicates are hidden so the list matches the dashboard. Set to true for power-user audits. |
Response (200)
{
"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
/v1/feedback/:idAuth: sk_ keyFull 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
| Name | Type | Description |
|---|---|---|
| 400 | invalid_id | id is not a valid 24-char hex id. |
| 404 | not_found | No feedback with that id in your Org (cross-Org reads return 404, not 403). |
PATCH /v1/feedback/:id
/v1/feedback/:idAuth: sk_ keyCustomer lifecycle nudge. The full FSM lives server-side; this endpoint exposes two legal moves:
Body
| Name | Type | Description |
|---|---|---|
| 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)
{ "id": "69e2f2d0ad2f5eef87449d83", "status": "queued" }Errors
| Name | Type | Description |
|---|---|---|
| 400 | invalid_body | invalid_id | no_op | Bad body, bad id, or empty patch. |
| 404 | not_found | Feedback id not in this Org. |
| 409 | invalid_transition | Current status doesn't allow the requested move. Response includes from and to so the caller can render an explanation. |
| 409 | concurrent_update | invalid_target | Another 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
/v1/agent-runs/:idAuth: sk_ keyPer-run audit row. Every run leaves one of these whether it produced a PR or failed.
Response (200)
{
"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:
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.