---
title: 'Feedback Endpoint'
description: 'Send one feedback submission to a feedback channel'
slug: api/feedback/endpoint
---

`POST https://ingest.modem.dev/feedback` accepts one feedback submission for a [feedback channel](/docs/integrations/in-app-feedback#feedback-channels). The [feedback widget](/docs/integrations/in-app-feedback#the-feedback-widget) and the [feedback MCP server](/docs/api/feedback/mcp-server) both call it. Call it yourself from a backend, a CLI, a mobile app, or anything else that can make an HTTPS request.

## Authentication

Send the channel's feedback token as a bearer token:

```http
Authorization: Bearer feedback_<32 hex characters>
```

A missing or malformed header gets `401`. A token that is unknown, revoked, expired after a rotation, or whose channel is disabled also gets `401`, with the same body, so a caller cannot tell those cases apart. Check the channel in **Settings** → **Feedback Channels**.

This is the only ingest route that accepts browser requests. If the channel has allowed origins, the request's `Origin` header must match one exactly or the response is `403`. Requests without an `Origin` header always pass.

## Request

Send either JSON or multipart form data. Any other content type gets `415`.

### JSON

```http
POST /feedback HTTP/1.1
Host: ingest.modem.dev
Authorization: Bearer feedback_…
Content-Type: application/json

{
    "submission_id": "0192e2f0-1a2b-7c3d-8e4f-5a6b7c8d9e0f",
    "message": "Exports time out for workspaces with more than 10k topics.",
    "name": "Ada Lovelace",
    "email": "ada@example.com",
    "url": "https://app.example.com/exports",
    "source": "api",
    "tags": { "category": "user_feedback", "plan": "pro" }
}
```

### Multipart

Use `multipart/form-data` to attach screenshots or to name the reporter from your backend. Only three part names are allowed; any other part gets `400`.

| Part         | Count  | Content                                                                                                   |
| ------------ | ------ | --------------------------------------------------------------------------------------------------------- |
| `payload`    | 1      | The JSON body above.                                                                                      |
| `screenshot` | 0 to 2 | An image file. PNG, JPEG, GIF, or WebP, at most 5 MB each. Modem checks the bytes, not the declared type. |
| `author`     | 0 or 1 | JSON naming the signed-in user of your app. See [The author part](#the-author-part).                      |

The whole request must stay under 12 MB.

```bash
curl https://ingest.modem.dev/feedback \
    -H "Authorization: Bearer $MODEM_FEEDBACK_TOKEN" \
    -F 'payload={"submission_id":"0192e2f0-1a2b-7c3d-8e4f-5a6b7c8d9e0f","message":"The export never finished.","source":"api"};type=application/json' \
    -F 'screenshot=@export-timeout.png' \
    -F 'author={"user_id":"usr_8213","name":"Ada Lovelace","email":"ada@example.com"};type=application/json'
```

## Payload fields

Unknown fields are rejected.

| Field                 | Type   | Required | Description                                                                                                                                                        |
| --------------------- | ------ | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `submission_id`       | string | Yes      | A UUID version 7 you generate. It is the idempotency key; see [Retries](#retries).                                                                                 |
| `message`             | string | Yes      | The reporter's words, 1 to 4,096 characters after trimming.                                                                                                        |
| `name`                | string | No       | Self-reported display name, up to 256 characters.                                                                                                                  |
| `email`               | string | No       | Self-reported email, up to 320 characters. Recorded as a claim, not verified. A malformed address does not reject the submission but is not used to link a person. |
| `url`                 | string | No       | The page the reporter was on. `http` or `https` only, up to 2,048 characters.                                                                                      |
| `source`              | string | No       | One of `widget`, `crash`, `tui`, `agent`, `api`. Defaults to `widget`.                                                                                             |
| `associated_event_id` | string | No       | The error-tracking event a crash report is about, 1 to 128 characters.                                                                                             |
| `replay_id`           | string | No       | A session replay ID, 1 to 128 characters.                                                                                                                          |
| `trace_id`            | string | No       | A trace ID, 1 to 128 characters.                                                                                                                                   |
| `release`             | string | No       | Your app's version, 1 to 200 characters.                                                                                                                           |
| `environment`         | string | No       | For example `production`, 1 to 64 characters.                                                                                                                      |
| `tags`                | object | No       | Up to 20 string labels. Keys 1 to 32 characters, values up to 200.                                                                                                 |
| `context`             | object | No       | Free-form JSON about what the reporter was doing. At most 8,192 characters when serialized.                                                                        |

`identity` is reserved for a future verified-identity feature and is rejected if sent.

### The category tag

`tags.category` says what kind of report this is. It is a convention on `tags`, not a separate field:

| Value            | Meaning                                                                                                   |
| ---------------- | --------------------------------------------------------------------------------------------------------- |
| `user_feedback`  | The reporter's own words. The default when the tag is absent.                                             |
| `agent_friction` | An AI agent's own report of a failing tool, a missing capability, or an unclear error, sent with consent. |

### The author part

When your backend already knows who is signed in, send an `author` part instead of asking the reporter to type their name and email:

| Field     | Type   | Required | Description                                      |
| --------- | ------ | -------- | ------------------------------------------------ |
| `user_id` | string | Yes      | Your app's ID for the user, 1 to 128 characters. |
| `name`    | string | No       | Up to 256 characters.                            |
| `email`   | string | No       | A malformed address is dropped, not refused.     |

Modem records the author as a **claimed** identity keyed by your user ID and this channel. It is not treated as a verified identity, because nothing proves what an app says about its own users.

## Responses

| Status | Meaning                                                                                                                |
| ------ | ---------------------------------------------------------------------------------------------------------------------- |
| `202`  | Accepted. The body is `{ "id": "…" }`, the ID of the feedback event. Accepted means queued for processing, not stored. |
| `400`  | Invalid JSON, a payload that fails validation (`details` lists the fields), or an unexpected multipart part.           |
| `401`  | Missing, malformed, or inactive token.                                                                                 |
| `403`  | The request's origin is not in the channel's allowed origins.                                                          |
| `409`  | A request with the same `submission_id` is still being accepted. Wait for `Retry-After` (one second) and retry.        |
| `413`  | A screenshot is over 5 MB, or the request is over 12 MB.                                                               |
| `415`  | Unsupported content type, or a screenshot that is not a PNG, JPEG, GIF, or WebP.                                       |
| `429`  | Rate limited. Wait for `Retry-After` (60 seconds).                                                                     |
| `500`  | Modem could not record the submission. Retry with the same `submission_id`.                                            |
| `503`  | Screenshots are temporarily unavailable. Retry without them, or later.                                                 |

Browsers can read `Retry-After` on `409` and `429`; it is the only response header exposed to them.

## Retries

Generate `submission_id` once per submission and send the same one on every retry of that submission. Modem dedupes on the channel and `submission_id`: once the first request is accepted, every later request with that ID gets `202` with the first submission's `id` and nothing is stored twice. If the reporter edits the draft, that is a new submission with a new ID.

Retry a failed connection, a `409`, a `429`, or a `5xx`. A `4xx` other than those will fail the same way again.

## Rate limits

Each token allows 60 submissions per minute, and each client IP allows 20 per minute. Over either limit the response is `429` with `Retry-After: 60`.

## Related

**[In-app Feedback](/docs/integrations/in-app-feedback)**

Channels, tokens, the widget, and what happens to a submission.

**[Feedback MCP server](/docs/api/feedback/mcp-server)**

The same endpoint as an MCP tool for your own AI agent.
