# Collecting feedback > Let your users and QA testers file reports from your own site, with a publishable key that is safe to ship in a browser bundle. [Reports](/concepts/reports) usually come from people who do not have a Myna account — a tester on a phone, a user who clicked "report a problem". Myna accepts those submissions directly from your site, using a **publishable ingest key**, and hands the answer back through your own product. ## Reports are submitted, never harvested Myna does not screenshot the page, record the session, read the DOM, wrap `fetch`, patch `console`, or fingerprint the browser. A report is: - what the person typed on a form, - the files they chose to attach, - and the `context` **your application** decided to send. Your security review is reading a form post, not a telemetry pipeline. ## Ingest keys An ingest key (`myna_ik_…`) is the opposite of a secret key. It is **designed** to sit in your JavaScript bundle where every visitor can read it. ```bash myna feedback keys create --name "Web app" --board qa ``` Omit `--board` and the key files on any board on the project, with each submission naming which. A key bound to a board is refused if it names a different one. What stops it being useful to whoever copies it: | Control | What it does | |---|---| | Write plus your own reads | It can file a report, and read back only what the reporter presenting it wrote | | Origin binding | Refused unless the request carries an `Origin` your project registered | | Rate buckets | Per-IP, per-project, and tighter than any authenticated surface | | Daily cap | A durable per-project ceiling that replica-hopping does not evade | | Field ceilings | Title, body, context size, attachment count and size | | Byte sniffing | Uploads are checked against their real leading bytes, not the declared type | Add your origins first, or every submission is refused: ```bash myna projects update --origins https://app.example.com ``` ## Rotating a key A key in a browser bundle gets copied. Rotating one mints a new secret and leaves the old one working for **24 hours**, which is the time a frontend needs to be rebuilt and redeployed: ```bash myna feedback keys rotate ing_01k2… myna feedback keys rotate ing_01k2… --identity-secret ``` Both secrets verify during the window; a durable job hard-revokes the old one at the end of it. `myna feedback keys revoke` skips the window entirely — it withdraws the credential now, mid-rotation secret included. The identity signing secret rotates separately, because the two live in different places: the key is in a browser bundle by design, the secret is on your server and its exposure is an incident. Rotating one should not force a redeploy of the other. ## Submitting Install the client. It has no dependencies and no framework. ```bash npm install @myna-sh/sdk ``` ### React `@myna-sh/react/feedback` is the whole reporter surface — the form, their reports, and the thread. See [React feedback components](/sdk/react-feedback). ### Build your own form The headless client is the primary surface. Render whatever form you like: ```js import { createFeedback } from "@myna-sh/sdk/feedback"; const feedback = createFeedback({ ingestKey: "myna_ik_…", board: "qa", // only needed when the key is not bound to a board context: () => ({ appVersion: BUILD.version, commit: BUILD.sha, route: location.pathname }), }); // Every question the board asks, in order — including the ones filling the // report's own title and body, each named by `target`. const { fields } = await feedback.schema(); const { number } = await feedback.submit({ title, body, fields: { buildNumber }, reporter: { email }, attachments: [...fileInput.files], }); ``` `schema()` returns the board's whole [intake form](/concepts/reports#boards), so rendering the list renders the form — there is no question you are expected to supply yourself. Read each answer back by its field's `target`: `title` and `body` are the arguments of the same name, `reporterEmail` is `reporter.email`, `attachments` is the `attachments` array (render a file input for it, taking at most its `max`), and a field with no `target` belongs in `fields[key]`. ### Or use the built-in form ```js import { mountFeedbackForm } from "@myna-sh/sdk/feedback"; mountFeedbackForm({ ingestKey: "myna_ik_…", target: "#report" }); ``` Structural markup only, with `data-myna` attributes on every element to hang your own CSS on. It renders the board's form and nothing else — every label comes from the board, so this widget and your own form ask the same questions in the same words. That covers the file input too: it appears when the board's form declares an attachments question, in the position the board put it. Reword the questions in board settings; `labels` here only covers what the board does not declare (the button, the confirmation, and a fallback attachment prompt). Because the form is the board's, it arrives one round trip late and the widget shows a status line until it does. ## Identifying your users An unsigned claim about who somebody is is not identity, so Myna ignores one. If your application has a backend, it can vouch for a user by signing their id: ```ts // On your server — never in the browser. import { signIdentity } from "@myna-sh/sdk/feedback/server"; const signature = signIdentity(process.env.MYNA_IDENTITY_SECRET, user.id); ``` ```js createFeedback({ ingestKey: "myna_ik_…", identify: { id: user.id, email: user.email, properties: { plan: "enterprise", seats: 40 }, signature, }, }); ``` Mint the secret alongside the key: ```bash myna feedback keys create --name "Web app" --board qa --signed-identity ``` The secret is shown once. It must stay on your server — publishing it would let anyone assert they are anyone. `@myna-sh/sdk/feedback/server` is a separate entry point for that reason: it imports `node:crypto`, so a bundler resolving the browser entry can never pull it in behind your back. A signed submission buys three things: - **Higher ceilings.** It skips the quota-driven spam queue entirely. - **Properties.** `properties` is what *your* system knows about its own user — plan, seat count, the tenant they belong to. It is stored on the reporter and filterable from the inbox, so a triager can tell an enterprise regression from a hobbyist's. At most 32 keys and 4 KB, replaced on every signed submission rather than merged. - **Reading the report back.** Only a signed reporter can, and only their own. The signature is over the id alone, not over the email, name or properties. Those are things your system knows and may change between page loads; re-signing on every change would make the signature a cache key rather than a statement of identity. Myna trusts them because it trusts the id they arrived with. ### Boards that require it A board can refuse anything unsigned: ```bash myna feedback boards update qa --require-identity ``` An unsigned submission then answers [`IDENTITY_REQUIRED`](/errors/identity-required), a 401 whose code says which of the two credentials is missing. Turn it on for a board inside a signed-in product; leave it off for a form anyone can reach. ## Reading a report back The person who filed a bug reads the answer **in your product**, not on a Myna page. There is no reporter-facing page for Myna to serve: your user is already signed in with you, and Myna's own identity model is GitHub OAuth, which is not a thing to ask of somebody who reported one bug. The same client does it, authenticated by the ingest key plus the signed identity: ```js const mine = await feedback.myReports(); const report = await feedback.report(42); await feedback.reply(42, "Still happening on the latest build."); await feedback.confirm(42); // "It works now" await feedback.reopen(42, "Same as before."); // "Still broken" ``` Under the hood that is an `X-Myna-Identity: .` header — the same HMAC a submission carries, so one code path validates both. Never put it in a query string: a query string is written to access logs, kept in browser history, and sent in a `Referer`. What comes back is deliberately thin. The report's title, body, status, and the **public** conversation, with the team attributed as "team" rather than by name. No priority, no labels, no assignee, no internal notes, no attachment manifest, no board guidance. A reporter is owed an answer about their problem, not a window into how your team works. A report belonging to somebody else answers exactly as a report that does not exist does, so the numbering cannot be walked to learn what a project has collected. ### Telling them something changed Set a board's app URL, and the notification emails Myna sends — received, replied, resolved, and "can you confirm this" — link there: ```bash myna feedback boards update qa --app-url https://app.example.com/support ``` Without one the email still says what happened and quotes the report number; it just has no button. Myna will not invent a URL it does not serve. ## What happens over the cap A project over its daily submission ceiling does **not** start refusing reports. Submissions are still accepted and stored, marked `spam` for a moderator to look at. Moving one back out is a single status change. A burst of genuine reports during an incident is when you most need them, so dropping the twentieth because the first nineteen arrived in the same hour is the wrong failure mode. ## Attachments Ask for an upload, send the bytes where it tells you to, then name the `uploadId` when you submit: ```bash # 1. Ask for an upload. Origin and ingest key, as with every ingest call. $ curl -X POST https://api.myna.sh/feedback/uploads \ -H "Authorization: Bearer myna_ik_..." -H "Origin: https://app.example.com" \ -H "content-type: application/json" \ -d '{"filename":"shot.png","contentType":"image/png","byteSize":20481}' # → { "uploadId": "rau_...", "url": "...", "method": "PUT", "headers": { ... } } # 2. Send the bytes with the url, method and headers it handed back, verbatim. $ curl -X PUT "" -H "Origin: https://app.example.com" \ -H "content-type: application/octet-stream" \ -H "x-myna-upload-token: " --data-binary @shot.png ``` Replay `url`, `method` and `headers` as they arrive rather than reconstructing them. Where the bytes go is Myna's decision, and a client that hard-codes it needs a release every time that changes — which is how the SDK followed this exact move without one. `@myna-sh/sdk`'s `upload()` and the React widget already do this for you. The bytes go to Myna, on an address your registered origins govern like every other call here. So an upload failing from a new domain is the same fix as a submission failing from one: `myna projects origins add`. Before, uploads went straight to storage with a presigned URL — cheaper, and unusable from a browser, because the storage bucket answers that preflight and nothing you configure on your project can change what it allows. Myna reads the file's leading bytes and refuses anything that is not what it claimed to be, before storing it. A "log file" that is really an HTML document is never written. So is a body larger than the size the upload was granted. Allowed types are images, `webm`/`mp4`/`quicktime` video, plain text, JSON, and PDF. An unclaimed upload expires and is swept, so nobody can ask for a thousand and walk away having reserved a thousand objects. A board that does not declare an attachments question takes no files at all: the upload endpoint refuses, and so does a submission that names uploads anyway. Refused rather than quietly stripped — dropping the ids would leave a reporter believing they had sent the screenshot that proves their bug. ## Errors worth handling | Code | Meaning | |---|---| | `PERMISSION_DENIED` | The `Origin` is missing or not registered on the project, the key named a board it may not file on, or an upload was sent without the token it was granted with | | [`IDENTITY_REQUIRED`](/errors/identity-required) | The board requires a signed identity, or a read-back call carried none | | [`PRODUCT_NOT_ENABLED`](/errors/product-not-enabled) | The project does not run Feedback | | `RATE_LIMITED` | Too many submissions from this address; the response carries `Retry-After` | | `VALIDATION_FAILED` | A field ceiling, or the board's intake form. `fields` names each path | `FeedbackError` from the client exposes `status`, `code`, and a `fields` record keyed by path, so validation messages bind straight back to your form.