Concepts

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 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.

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:

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:

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.

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.

Build your own form

The headless client is the primary surface. Render whatever form you like:

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, 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

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:

// 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);
createFeedback({
  ingestKey: "myna_ik_…",
  identify: {
    id: user.id,
    email: user.email,
    properties: { plan: "enterprise", seats: 40 },
    signature,
  },
});

Mint the secret alongside the key:

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:

myna feedback boards update qa --require-identity

An unsigned submission then answers 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:

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: <yourUserId>.<hmacHex> 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:

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:

# 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 "<url>" -H "Origin: https://app.example.com" \
    -H "content-type: application/octet-stream" \
    -H "x-myna-upload-token: <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 The board requires a signed identity, or a read-back call carried none
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.