Feedback Endpoint
Send one feedback submission to a feedback channel
POST https://ingest.modem.dev/feedback accepts one feedback submission for a feedback channel. The feedback widget and the 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:
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
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 whole request must stay under 12 MB.
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. |
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.