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:writefeedback:readfeedback:commentfeedback: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 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.
Before the first submission, bind that subject:
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:
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:
{
"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 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.
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. 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.