---
title: "Backend & CLI Feedback"
description: "Submit reporter-owned tickets from a trusted backend or CLI, preserve retries, and route replies to your product."
category: "SDKs & API"
order: 14
---

# Submit feedback from a backend or CLI

Use this flow when a product such as Stamp accepts feedback from a signed-in
person or agent. Your backend authenticates the caller, adds product context,
and submits a Hands ticket. A CLI calls that backend using its own product
session; it does not need a Hands administrator account.

Keep the Hands token and reporter identity secret on the backend. The browser
and CLI must not supply an arbitrary reporter id or choose another user's
Hands ticket. Hands owns tickets and conversations; your product owns login,
local drafts, and delivery of notifications to its users.

## Set up a product app and integration

Create a dedicated Hands app for the product. A web product can use the `web`
platform. Do not submit its tickets to another product's app.

An app administrator creates an active reporter integration and a deploy token
bound to it, with no app role and these scopes:

- `feedback:write`
- `feedback:read`
- `feedback:comment`
- `feedback:route`

Use `POST /api/apps/:appId/reporter-integrations`, then
`POST /api/apps/:appId/deploy-tokens` with `reporter_integration_id`, the scopes,
and no `app_role`. The [setup example](feedback-react.md#one-time-hands-setup)
shows the full requests. Store the one-time token in your backend's secret
manager, together with the app client key from the Hands console.

A client key alone allows submission but does not authenticate a reporter for
reading tickets or receiving routed replies. An ordinary app-role token is
also not a substitute for the feedback-only reporter-integration token.

## Establish the caller's reporter identity

Derive a stable, opaque reporter id on the backend from the authenticated
principal, scoped to the Hands app and reporter integration. Use a keyed HMAC;
include the authentication namespace/server and principal type when the product
has multiple identity providers. Do not use a display name or email as the id.
The result must be 16–200 base64url characters.

Derive a separate opaque route subject, `rfr_v1_` followed by base64url
characters, for notification routing. Keep a backend mapping to the product's
user/agent, or derive it reproducibly. See the
[identity derivation example](feedback-react.md#derive-opaque-reporter-coordinates).

Before the first submission, bind that subject:

```http
PUT /api/apps/:appId/reporter-feedback/route-subject
Authorization: Bearer <server-side reporter-integration token>
X-Hands-Reporter-Id: <backend-derived reporter id>
Content-Type: application/json

{"route_subject":"rfr_v1_<backend-derived value>"}
```

A new binding returns `201`; the same binding returns `200`. A conflicting
binding returns `409`. Stop and resolve a conflict; do not silently assign the
reporter to a different destination. A trusted submission without this binding
returns `409` with `error: route_required`.

## Submit a text ticket

Send multipart form data, including for text-only feedback:

```http
POST https://hands.build/public/v2/apps/:appSlug/feedback
Authorization: Bearer <server-side reporter-integration token>
X-Hands-Reporter-Id: <backend-derived reporter id>
X-Hands-Client-Key: <app client key>
Content-Type: multipart/form-data; boundary=<generated by the HTTP client>
```

| Form field | Value |
| --- | --- |
| `message` | Required text, 1–10,000 characters. |
| `kind` | `feedback` for an idea, or `bug` for a problem. |
| `submission_id` | A UUID generated once for this submission and preserved across retries. |
| `metadata` | A JSON object encoded as a form string. |
| `contact` | Optional contact information, only with the reporter's consent. |

Let your HTTP client's `FormData` implementation set the multipart boundary.
Do not put the client key or bearer token in a URL.

Metadata is stored with the ticket. Shared fields such as `version_name`,
`version_code` (a JSON number), `channel`, and `locale` populate the matching
Hands columns. Put product-specific fields under a namespaced object, for
example:

```json
{
  "version_name": "0.1.0",
  "locale": "en",
  "stamp": {
    "server_id": "<product server id>",
    "principal_type": "agent",
    "error_code": "REPOSITORY_NOT_AUTHORIZED",
    "receipt_id": "<product receipt id>",
    "page_path": "/repositories"
  }
}
```

The `stamp` namespace is an integration convention, not a special Hands schema.
Allowlist context fields on your backend. Strip access tokens, cookies,
authorization headers, private keys, and sensitive URL query parameters before
submission. Metadata describes the problem; it does not establish ticket
ownership. The authenticated reporter coordinates do that.

A new submission returns `201` with `id`, `status`, `reference`, and
`idempotent_replay: false`. An exact retry returns `200`, the same ticket id,
and `idempotent_replay: true`. A reused submission id with different content or
reporter ownership returns `409`.

Keep the draft after network errors, timeouts, or `429`; retry the same content
with the same UUID. Respect `Retry-After`. Do not generate a new UUID just
because the response was lost: Hands may already have stored the ticket.
If the caller changes the content, treat it as a new submission rather than
retrying an old key with a different payload.

Show the returned reference or ticket id as a support receipt. `ticket_url`
points to the Hands staff console; it is not a reporter-facing status link.

## Read and reply as the reporter

Your backend uses the same integration token and derived
`X-Hands-Reporter-Id` for these operations:

| Operation | Endpoint |
| --- | --- |
| List owned tickets | `GET /api/apps/:appId/reporter-feedback` |
| Read ticket and public conversation | `GET /api/apps/:appId/reporter-feedback/:ticketId` |
| Add a reply | `POST /api/apps/:appId/reporter-feedback/:ticketId/comments` |
| Close an owned ticket | `POST /api/apps/:appId/reporter-feedback/:ticketId/close` |

Ownership is checked against the app, integration, and reporter tuple. Another
reporter's ticket returns `404`. Reply submissions also use a stable
`submission_id`; see the [wire contract](public-api-reference.md) for bodies,
pagination, and limits. Internal staff comments are excluded from the
reporter's conversation.

## Notify the product's user or agent

Hands does not automatically send a Raft message to the submitter. You can poll
the reporter endpoints, or configure a signed webhook to your backend and
forward the notification through your product's existing user/agent channel.

Use your **app-admin Hands session** to create the webhook. The app creator is
an app admin; organization-admin privileges are not required for this path.
The feedback-only reporter token cannot perform these setup operations.

```http
POST /api/apps/:appId/webhooks
Authorization: Bearer <app-admin-session>
Content-Type: application/json

{
  "url": "https://your-product.example/webhooks/hands",
  "secret": "<signing-secret-from-backend-storage>",
  "events": ["feedback:comment_created", "feedback:status_changed"]
}
```

A successful `201` returns the webhook `id`, app scope, and `secret_set: true`,
but never the signing secret. The app is selected by the path's full UUID;
there is no need for an `app_id` body field. The manifest action is
`create-app-webhook`; metadata discovery uses `list-app-webhooks`.

With the same app-admin session, bind the returned id using
`PUT /api/apps/:appId/reporter-integrations/:integrationId/webhooks/:webhookId`.
No request body is needed. Creation alone does not bind the reporter subscriber.
These subscriptions are separate from a generic new-ticket staff alert.

The organization path `/api/orgs/:orgId/webhooks` is a separate management
surface that requires **org admin**, even if its body includes `app_id`.
The manifest's legacy `create-webhook` action uses that organization path.
Use the app path above for this integration instead of requesting broader
organization privileges.

Verify `X-Hands-Signature` (HMAC-SHA256) over the exact request bytes before
routing. Deduplicate the stable `X-Hands-Event-Id`; each delivery also has an
`X-Hands-Delivery-Id`. Use the signed payload's `route_subject` to locate your
recipient. Retries reuse the event id, delivery id, signature, and body.
Keep polling available for recovery; the Hands ticket is authoritative.

## Integration acceptance checks

Before enabling your entry point:

- Provision the product's own app, client key, reporter integration, token, and
  route binding. Store credentials only on the backend.
- Submit once and retry the identical payload: receive one ticket with the same
  id, not two tickets. Changing content under the same UUID must return `409`.
- Use two authenticated principals to confirm cross-reporter reads return `404`.
- Preserve drafts after failed requests and `429`, with the same submission id.
- Add a public staff reply and confirm polling or the signed webhook reaches
  the intended recipient; an internal staff note must remain hidden.

For a React interface that already manages drafts, reply state, attachments,
and retry controls, see [React Feedback Inbox](feedback-react.md). A native or
CLI client can implement the same backend contract without React. Direct
browser-to-Hands CORS is not needed for this backend-proxy architecture.
