- Guides
- Build your own report button
Build your own report button
Report bugs from your own UI instead of Espejo's floating button. Turn the button off, call report() from your form, record the screen with your own controls and handle errors and limits.
Espejo mounts a floating report button by default (a bug icon, labeled “Reportar un problema”; you can change the text, see Constructor options). It is the quickest way to start, but your product may already have a place for this: a help menu, a feedback form, a support page. This guide shows how to turn our button off and report from your own UI with the SDK. The recording is the same: network, console, clicks and navigation (plus the DOM replay or a screen video when you use them) travel with the report.
Use it when:
- You want the report to look and read like the rest of your product.
- You already have a “report a problem” flow and want the recording to travel with it.
- You want to decide where and when people can report, for example only for signed in users or only on some pages.
Install with the button off
Section titled “Install with the button off”From npm
Section titled “From npm”Install the package. It works the same with pnpm or yarn:
npm install @espejo/browserCreate the recorder once, in the browser, and start it. With button: "off"
the floating report button is not mounted. Capture works exactly the same.
Other Espejo widgets can still appear: see
Other Espejo widgets.
import { Espejo } from "@espejo/browser";
export const espejo = new Espejo({ key: "pk_live_...", button: "off",});espejo.start();new Espejo()throwsEspejo: missing data-key (the project's public key)withoutkey.- Nothing is recorded until you call
start(). Beforestart(), and afterstop(),report()returnsnull. - Call
start()once. A second call while it is recording does nothing. - It needs a browser: create it on the client side, after the page loads, not during server side rendering.
TypeScript types are included, so there is nothing else to install.
With the script tag
Section titled “With the script tag”If you do not use a bundler, add data-button="off" to the script:
<script src="https://app.espejo.dev/sdk/espejo.js" data-key="pk_live_..." data-button="off"></script>The script starts on its own and leaves the instance in window.espejo.
Without data-key it records nothing and does not set window.espejo. Load
it without async or defer if an inline script right after it uses
window.espejo, or wait for the load event. Use espejo.dom.js instead of
espejo.js to add the DOM replay, as in the
Quickstart.
Other Espejo widgets
Section titled “Other Espejo widgets”button: "off" only removes the report button. Two more pieces of Espejo can
still show up on your page, depending on how the project is set up in the
console:
- The feedback survey. It appears only if the project enabled it. To
suppress it on a page, pass
feedback: "off"or adddata-feedback="off". It can only turn it off, never on. See Feedback. - Live messages (the banner or modal of a behavior). They appear only if the project configured a message. There is no option in the page to turn them off: manage them in the console. See Behaviors.
const espejo = new Espejo({ key: "pk_live_...", button: "off", feedback: "off",});Report from your button
Section titled “Report from your button”espejo.report(description); // Promise<string | null>espejo.report(description, undefined, onProgress); // with upload progressdescription: what the person wrote. It travels with the recording as plain text. An empty string is stored as no description.- The second argument: always leave it out, or pass
undefinedwhen you need the third one. Espejo uses it for its own reports. onProgress: optional upload progress, from 0 to 1. See Upload progress.
It resolves to a string that identifies the report, or to null when the
recorder is not running. It is meant for your team, not for the person who
reported: do not show it to them. Do not rely on it to open the recording.
After a report that resolves, espejo.lastReportId holds the id of that
report (32 hex characters). It stays null until the first report on the
page. Save it next to your own ticket or row to link the report to your own
system.
Each report sends what was recorded since the previous one and then starts over, so two reports in a row do not send the same events twice. An automatic report counts as a previous report too.
It rejects when the upload fails. See Errors and limits.
A complete example with npm
Section titled “A complete example with npm”A form with its own text area and button. It disables the button while it sends, confirms with a short message and shows a generic error when the upload fails.
<form id="report-form"> <label for="report-text">What went wrong?</label> <textarea id="report-text" rows="4" required></textarea> <button id="report-send" type="submit">Send report</button> <p id="report-status" role="status"></p></form>import { espejo } from "./espejo"; // the instance created above
const form = document.querySelector<HTMLFormElement>("#report-form")!;const text = document.querySelector<HTMLTextAreaElement>("#report-text")!;const send = document.querySelector<HTMLButtonElement>("#report-send")!;const status = document.querySelector<HTMLParagraphElement>("#report-status")!;
form.addEventListener("submit", async (event) => { event.preventDefault(); const description = text.value.trim(); if (!description) return;
send.disabled = true; status.textContent = "Sending..."; try { const result = await espejo.report(description); if (result === null) { status.textContent = "Reporting is not available right now."; return; } text.value = ""; status.textContent = "Thanks. We got your report."; // Optional: keep the id on your side, next to your own ticket. await fetch("/api/support/reports", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ description, id: espejo.lastReportId }), }); } catch (error) { // console.warn and not console.error: see the note below. console.warn("Espejo report failed", error); status.textContent = "We could not send your report. Try again in a moment."; } finally { send.disabled = false; }});The /api/support/reports call is your own backend, not Espejo’s. Leave it
out if you do not need it.
Log a failed report with console.warn, not with console.error. When
automatic reports are on for the project, a console.error can trigger one.
console.warn never does.
The same example with the script tag
Section titled “The same example with the script tag”<script src="https://app.espejo.dev/sdk/espejo.js" data-key="pk_live_..." data-button="off"></script>
<form id="report-form"> <label for="report-text">What went wrong?</label> <textarea id="report-text" rows="4" required></textarea> <button id="report-send" type="submit">Send report</button> <p id="report-status" role="status"></p></form>
<script> const form = document.getElementById("report-form"); const text = document.getElementById("report-text"); const send = document.getElementById("report-send"); const status = document.getElementById("report-status");
form.addEventListener("submit", async (event) => { event.preventDefault(); const description = text.value.trim(); if (!description || !window.espejo) return;
send.disabled = true; status.textContent = "Sending..."; try { const result = await window.espejo.report(description); if (result === null) { status.textContent = "Reporting is not available right now."; return; } text.value = ""; status.textContent = "Thanks. We got your report."; } catch (error) { console.warn("Espejo report failed", error); status.textContent = "We could not send your report. Try again in a moment."; } finally { send.disabled = false; } });</script>With the support chat on the same page
Section titled “With the support chat on the same page”When the support agent opens a ticket, it can
attach the last recording the visitor reported on the page. The chat reads it
from window.espejo.lastReportId. The script tag sets window.espejo for
you. With npm, set it yourself so the chat finds it:
(window as any).espejo = espejo;Record the screen with your own controls
Section titled “Record the screen with your own controls”A screen video shows the bug better than any description. You can offer it from your own UI with these methods:
| Member | What it does |
|---|---|
startVideo(options?) | Opens the browser’s screen picker and starts recording. Resolves when the recording started. Does nothing if one is already running. |
stopVideo() | Stops the recording and keeps the video for the next report(). Resolves to the video as a Blob, or null when nothing was recording. |
setMicrophone(on) | Turns the microphone on or off without stopping the recording. Resolves to { on, denied }. |
discardVideo() | Drops a stopped video so the next report goes without it. |
setMarkup(on, onExit?) | Turns drawing over the screen on or off. See Drawing on the screen. |
recordingVideo | true while a screen recording is running. |
microphoneOn | true while the microphone adds audio. |
From npm you also get videoSupported(), which says whether the browser can
record the screen, and MAX_VIDEO_SECONDS (240):
import { videoSupported, MAX_VIDEO_SECONDS } from "@espejo/browser";
recordButton.hidden = !videoSupported();It needs a click
Section titled “It needs a click”startVideo() and the first setMicrophone(true) open a browser prompt, and
browsers only allow that from a user gesture. Call them directly inside a
click handler. Called outside a click, for example from a timer, the browser
refuses.
Options of startVideo
Section titled “Options of startVideo”| Option | Default | Meaning |
|---|---|---|
microphone | false | Start with the microphone on. You can change it later with setMicrophone(). |
surface | the videoSurface option, or the normal picker | What the picker offers first: current-tab (the current tab without a picker, in Chrome), browser, window or monitor. Except for current-tab, it is a preference: the person can still pick something else. |
onUserStopped | none | Called when the person stops sharing from the browser’s own bar. |
onLimitReached | none | Called when the recording reaches its time limit and stops on its own. |
maxSeconds | 240 | Seconds before the recording stops on its own. You can ask for less, never more than 240. |
maxHeight | 1080 | Maximum height of the capture, in pixels. Larger screens are scaled down. |
frameRate | 24 | Frames per second. |
videoBitsPerSecond | 600000 | Video bitrate. Raising it fills the 25 MiB video limit sooner. |
startVideo() rejects when the person closes the picker or denies it, and
with This browser can't record the screen. or
This browser doesn't support MediaRecorder for webm. when the browser
cannot record.
When the recording stops on its own (the person used the browser’s bar, or
the time limit), recordingVideo stays true until you call stopVideo() or
report(). Call stopVideo() from onUserStopped and onLimitReached so
your UI leaves the recording state.
A complete flow
Section titled “A complete flow”import { videoSupported } from "@espejo/browser";import { espejo } from "./espejo";
recordButton.hidden = !videoSupported();
recordButton.addEventListener("click", async () => { try { await espejo.startVideo({ onUserStopped: () => void finishRecording(), onLimitReached: () => void finishRecording(), }); } catch (error) { console.warn("Screen recording did not start", error); return; // the person closed the picker, or the browser cannot record } showRecordingControls(); // your UI: stop, microphone, draw});
micButton.addEventListener("click", async () => { const { on, denied } = await espejo.setMicrophone(!espejo.microphoneOn); micButton.setAttribute("aria-pressed", String(on)); // denied: the browser blocks the microphone for this site. Asking again shows nothing. if (denied) micButton.disabled = true;});
stopButton.addEventListener("click", () => void finishRecording());
async function finishRecording() { await espejo.setMarkup(false); const video = await espejo.stopVideo(); showReportForm(video); // the form of the previous section; the video goes with the next report()}
cancelButton.addEventListener("click", async () => { await espejo.setMarkup(false); await espejo.stopVideo(); espejo.discardVideo(); // nothing is sent hideRecordingControls();});stopVideo() gives you the video as a Blob, so you can let the person
watch it before sending, for example with URL.createObjectURL(video).
- The next
report()attaches the video, and then drops it. - If a recording is still running,
report()stops it first and attaches it. discardVideo()only drops a video that already stopped. To cancel a recording in progress, callstopVideo()first, as in the example.- A video you do not send or discard waits for the next
report()on the page. Discard it when the person closes your form without sending. setMicrophone()does nothing without a recording in progress and resolves to{ on: false, denied: false }.
Upload progress
Section titled “Upload progress”A video can take a few seconds to upload. Pass onProgress as the third
argument of report(), with undefined as the second:
await espejo.report(description, undefined, (fraction) => { status.textContent = `Sending... ${Math.round(fraction * 100)}%`;});It is only called when the report carries a video. Treat it as extra information and not as a sign that the upload is alive: some browsers call it once at 100%, and behind some proxies it is never called. Show “Sending…” from the start and add the number when it arrives.
Drawing on the screen
Section titled “Drawing on the screen”setMarkup(true, onExit) lays a full screen canvas over the page so the
person can point at the problem while recording. It resolves to true when
drawing is on, and to false when it could not start. Then nothing else
happens and the recording goes on. setMarkup(false) turns it off and
resolves to false.
- Use it while recording. The drawing only exists in the screen video: it is not saved anywhere else and it does not produce an image.
- The canvas covers the whole page, your UI included, so clicks do not reach the page while it is on. Espejo shows its own toolbar at the bottom right: pen, rectangle, five colors and undo. Strokes fade a few seconds after the last movement.
- The person leaves with Escape, which calls
onExit. To offer your own button to leave, give itposition: fixedandz-index: 2147483647so it stays above the canvas, and callsetMarkup(false)from it. - The first call downloads
https://app.espejo.dev/sdk/espejo.draw.js. If your site has a Content Security Policy, allow scripts fromhttps://app.espejo.dev, orsetMarkupresolves tofalse. - Turn it off before you stop the recording, as in the example: stopping the video does not remove the canvas.
Who reported, and on which version
Section titled “Who reported, and on which version”Pass an opaque id of your user so the recording can be traced back to that person. Never an email or a name: it travels in plain sight in the browser.
// At sign inespejo.identify("user-123");// At sign outespejo.identify(null);You can also set it from the start with the userRef option or the
data-user-ref attribute. The rules and where it shows up are in
Identify the user.
release and env label every recording with your app’s version and
environment, so you can tell which build a bug came from:
const espejo = new Espejo({ key: "pk_live_...", button: "off", release: "2026.09.30", env: "production",});With the script tag, data-release and data-env.
Constructor options
Section titled “Constructor options”The options that matter when you report from your own UI. Each one has its script attribute.
| Option | Attribute | Default | Meaning |
|---|---|---|---|
key | data-key | none | The project’s public key (pk_live_...). Required. |
button | data-button | on | off does not mount the floating report button. It does not affect the feedback survey or live messages. |
feedback | data-feedback | on | off suppresses the feedback survey on this page. It cannot turn it on: the project decides in the console. See Feedback. |
capture | data-capture | events | dom adds the DOM replay. With the script tag, load espejo.dom.js instead. |
release | data-release | none | Your app’s version, shown on the recording. |
env | data-env | none | Your environment, for example production or staging. |
userRef | data-user-ref | none | An opaque id of your user. Change it later with identify(). |
videoSurface | data-video-surface | the normal picker | The default surface for startVideo(). A value set for the project in the console wins over it. |
autoReport | data-auto-report | on | off turns automatic reports off on this page. It cannot turn them on. See Automatic reports. |
buttonText, buttonIcon and position style Espejo’s button, so they do
nothing with button: "off". buttonText is a constructor option only (it has
no script attribute) and replaces the texts field by field. The default texts
are in Spanish, for example:
new Espejo({ key: "pk_live_...", buttonText: { triggerLabel: "Report a problem", title: "Report a problem" },});Other members you may use:
| Member | What it does |
|---|---|
start() | Starts recording. |
stop() | Stops capturing and removes the report button, the feedback survey and the live messages. It does not stop a screen recording in progress: call stopVideo() first. After stop(), report() returns null. |
recording | true between start() and stop(). |
lastReportId | The id of the last report a person sent on this page, or null. Automatic reports do not change it. |
snapshot() | What is recorded so far, without uploading anything, or null when not recording. Useful to check what a report would send. |
Errors and limits
Section titled “Errors and limits”report() rejects in these cases. Catch the error, show the person a generic
message and log the details with console.warn.
When report() rejects, nothing is consumed: the recorded events, the video
and lastReportId stay as they were. Trying again sends the same events and
the same video, plus what was recorded in between.
| Message | When |
|---|---|
ingest rejected the session: HTTP <status> <body> | Espejo answered with an error. The status and the first 200 characters of the answer are in the message. |
ingest unreachable: the upload could not be completed | The upload with video and onProgress could not reach Espejo. |
the upload was cancelled | The upload with video and onProgress was cancelled. |
ingest returned a body that is not JSON | The upload with video and onProgress got an answer that is not JSON. |
| The browser’s own network error | The request could not reach Espejo, without onProgress. The text depends on the browser. |
The statuses you can get:
| Status | Cause | What to do |
|---|---|---|
403 | Missing, invalid or revoked key. | Check key or data-key. |
403 | The page’s origin is not in the project’s allowed origins. The answer says Origin <origin> is not allowed for this project. | Add the origin in the console. A project with no allowed origins accepts any. |
403 | The project reached its daily limit of sessions (Daily limit of <n> sessions reached.) or of bytes (Daily byte limit reached.). | The limits reset at midnight UTC. Automatic reports count toward the same limits. |
403 | The project is paused, or the account is deactivated. | Check the project in the console. |
413 | The recording or the video is too large. | See the sizes below. |
503 | Espejo is receiving more than it can hold at the moment (it sends Retry-After), or the project’s storage is not configured. | Ask the person to try again in a few seconds. |
Limits:
- Recording: up to 8 MiB per report, not counting the video. The recorder keeps a capped buffer in memory and drops the oldest events first.
- Video: up to 25 MiB and 240 seconds. With the default bitrate, 240
seconds normally fit in that size. A higher
videoBitsPerSecondcan go over it. - Daily limits: sessions and bytes per project per day, the same for reports from your UI, from our button and automatic ones.
- Allowed origins: when the project has a list, only pages on those origins can report.
What isn’t available yet
Section titled “What isn’t available yet”- Contact details of the reporter. There is no field for an email or a
name. Use
identify()with an opaque id and keep the contact on your side. - Custom fields or metadata. A report carries its description,
release,envanduserRef, and nothing else of your choosing. - Severity or category. There is no field for them. If you need them,
keep them in your own system next to
lastReportId. - Attachments or screenshots. A report cannot carry files or images. The drawing tool only appears in the screen video.
- Source maps. Stack traces in the recording are shown as the browser reported them. Minified code is not mapped back to your sources.
- Reporting over HTTP. The report is made with the SDK. There is no public HTTP contract to upload a recording from your own client.