# Feedback

How feedback is sent from the site, and the operator endpoints that read and triage it.

Ideas, bug reports and questions are sent from the [feedback page](/feedback)
(linked in the header and footer). The site's operators read them through a
private, token-protected API.

## Sending feedback

`POST /feedback` takes a regular HTML form
(`application/x-www-form-urlencoded`). No account is needed.

| Field     | Required | Rules                                                 |
| --------- | -------- | ----------------------------------------------------- |
| `kind`    | yes      | `idea`, `bug`, `question` or `other`                  |
| `message` | yes      | 10–5,000 characters                                   |
| `name`    | no       | Up to 100 characters                                  |
| `email`   | no       | A valid address, up to 254; used only to reply to you |
| `page`    | no       | The site path you were on, e.g. `/docs`               |

A successful post redirects (`303`) to `/feedback?sent=1`. Invalid fields
re-render the form with a message next to each one (`400`).

Spam protection, so please use the form rather than scripting it:

- The `Origin` header must match the site, so cross-site posts get `403`.
- The form carries a hidden honeypot field and the time it was shown.
  Posts that fill the honeypot or arrive within 3 seconds are quietly
  dropped.
- At most 5 messages per address per hour, then `429`.

## Sending feedback from a script or an agent

`POST /api/v1/feedback` takes the same message as JSON. No token is needed;
with one, the message carries your handle unless you give a `name`.

```bash
curl -s https://releaseszn.com/api/v1/feedback \
  -H 'Content-Type: application/json' \
  -d '{"kind": "bug", "message": "POST /projects answers 500 for a repository with no releases.", "page": "/docs/errors"}'
```

```json
{ "received": true }
```

The fields and their rules are the form's (`kind`, `message`, optional
`name`, `email`, `page`); an invalid one is a `422 validation_failed`
listing each problem, and a field we don't know a `400 invalid_body`. The
limit is the form's too: 5 messages an hour per account, or per address
without a token, then `429 feedback_limited` with `Retry-After`. For a bug,
say what you sent, the error `code` that came back, and what you expected.

## Reading feedback (operators only)

> [!NOTE]
> These endpoints are **not public**. They listen on a separate port bound
> to localhost (`http://127.0.0.1:8081/internal/v1`) and need
> `Authorization: Bearer <INTERNAL_API_TOKEN>`. Without the token they
> answer `401`.

| Method   | Path                    | Description                                |
| -------- | ----------------------- | ------------------------------------------ |
| `GET`    | `/feedback`             | The inbox, newest first                    |
| `POST`   | `/feedback/{id}/status` | Mark a message `new`, `read` or `archived` |
| `DELETE` | `/feedback/{id}`        | Delete a message for good                  |

### `GET /feedback`

| Parameter | Default | Notes                       |
| --------- | ------- | --------------------------- |
| `status`  | all     | `new`, `read` or `archived` |
| `limit`   | 50      | 1–500                       |

```bash
curl -s -H "Authorization: Bearer $INTERNAL_API_TOKEN" \
  "http://127.0.0.1:8081/internal/v1/feedback?status=new"
```

```json
{
  "messages": [
    {
      "id": 1,
      "kind": "idea",
      "message": "An RSS feed per tag would be great.",
      "name": "Ada",
      "email": "ada@example.com",
      "page": "/docs",
      "status": "new",
      "created_at": "2026-10-09T18:29:51.964287Z"
    }
  ]
}
```

`name`, `email` and `page` are `null` when not given. An unknown `status`
returns `400 bad_status`.

### `POST /feedback/{id}/status`

```bash
curl -s -H "Authorization: Bearer $INTERNAL_API_TOKEN" \
  -H 'Content-Type: application/json' -d '{"status":"read"}' \
  http://127.0.0.1:8081/internal/v1/feedback/1/status
```

```json
{ "id": 1, "status": "read" }
```

### `DELETE /feedback/{id}`

Removes a message for good, with the name, email and page that came with
it: what we do when you ask us to delete feedback you sent
([privacy](/docs/privacy)). There is no undo.

```bash
curl -s -X DELETE -H "Authorization: Bearer $INTERNAL_API_TOKEN" \
  http://127.0.0.1:8081/internal/v1/feedback/1
```

```json
{ "deleted": true, "id": 1 }
```

Errors use the [usual format](/docs/api): `400 bad_status` for a status
other than `new`, `read` or `archived`, `404 not_found` for an unknown id
(or a message already deleted).
