Widget and SDK

Sending reports from your own form

Updated 59 views

If your app already has a "Report a problem" dialog — built with your own components, in your own design — you can keep it. Call submit() with whatever your form collected, and the report arrives in BugDump with the same diagnostics the widget would have attached. BugDump's own panel never appears.

Your form or BugDump's panel?

There are two ways to run BugDump without its floating button:

Trigger

Form

Screenshot, recording and annotation tools

hideButton + open()

Yours

BugDump's panel

Yes

hideButton + submit()

Yours

Yours

No — pass any files you collect yourself

Pick open() when you want BugDump's capture tools and don't mind its panel. Pick submit() when the form has to look and behave like the rest of your app. See Choosing an installation method for the open() route.

Set up

Install the SDK and initialize it once, as early as you can, with hideButton: true:

Bash
npm install @bugdump/sdk
TypeScript
import { Bugdump } from '@bugdump/sdk';

const bugdump = Bugdump.init({
  apiKey: 'your-widget-key',
  hideButton: true,
});

Initialize at app start rather than when the dialog opens: console and network capture only cover what happened after init(), and the error your user is about to describe has usually already happened.

submit() needs version 0.0.40 or later.

Send a report

TypeScript
const { taskPublicId } = await bugdump.submit({
  description: 'I clicked Pay and nothing happened',
  reporterName: 'Jane Doe',
  reporterEmail: 'jane@example.com',
  priority: 'High',
  files: Array.from(fileInput.files ?? []),
});

Only description is required. submit() resolves with { id, taskId, taskPublicId } — taskPublicId is the task number your team sees on the board, handy for a "Thanks, your report is #42" message.

Option

Type

Description

description

string

Required. What happened, up to 10,000 characters.

reporterName

string

Falls back to the name passed to identify().

reporterEmail

string

Must be a valid email address. Falls back to the email passed to identify().

priority

string

A priority label as your project defines it, such as High. Matched case-insensitively. A label your project does not have is ignored and the report is still filed.

taskId

number

Attach the report to an existing task by its number instead of creating a new task. Falls back to identifyTask(). The task keeps its priority.

files

Blob[]

Uploaded as attachments. A File keeps its name.

The default priority labels are Low, Medium, High and Critical. If you renamed yours, use your labels — see Customizing priorities.

What is attached for you

You send the fields above; the SDK adds the rest, exactly as it does for widget reports:

  • console logs and network requests, after your consoleFilter and networkFilter

  • the user's recent clicks, typing and navigation

  • performance timings

  • the page URL, referrer, browser, and viewport

  • the user from identify() and the data from setContext()

  • a session replay, when the sessionReplay feature is on (the default, on every plan)

After a successful report the console, network and action buffers start fresh, so the next report does not repeat them. To keep something out of reports, use Filtering console and network capture and Feature toggles and handling sensitive data — they apply to submit() too.

React

With @bugdump/react, put hideButton: true in the provider's config and send from your form through the hook:

tsx
import { useState, type FormEvent } from 'react';
import { BugdumpApiError } from '@bugdump/sdk';
import { useBugdump } from '@bugdump/react';

// Rendered under <BugdumpProvider config={{ apiKey: import.meta.env.VITE_BUGDUMP_KEY, hideButton: true }}>
export function ReportForm({ onSent }: { onSent: (taskNumber: number) => void }) {
  const bugdump = useBugdump();
  const [sending, setSending] = useState(false);
  const [error, setError] = useState('');

  async function handleSubmit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    if (!bugdump) return;
    const data = new FormData(event.currentTarget);
    setSending(true);
    setError('');
    try {
      const { taskPublicId } = await bugdump.submit({
        description: String(data.get('description')),
        reporterEmail: String(data.get('email')) || undefined,
        files: data.getAll('files').filter((f): f is File => f instanceof File && f.size > 0),
      });
      onSent(taskPublicId);
    } catch (e) {
      setError(e instanceof BugdumpApiError ? e.message : 'Could not send the report.');
    } finally {
      setSending(false);
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <textarea name="description" required maxLength={10000} />
      <input name="email" type="email" />
      <input name="files" type="file" multiple />
      {error && <p role="alert">{error}</p>}
      <button disabled={!bugdump || sending}>{sending ? 'Sending…' : 'Send report'}</button>
    </form>
  );
}

useBugdump() is null until the widget has started, so the button waits for it. Keep the provider at the root, so capture starts with the app and already covers what happened before the user opened your dialog.

The f.size > 0 filter matters: an empty file input still submits one nameless, empty file.

Angular

Initialize in a service and inject it into your root component, so capture starts when the app does:

TypeScript
import { Injectable, PLATFORM_ID, inject } from '@angular/core';
import { isPlatformBrowser } from '@angular/common';
import { Bugdump, type SubmitOptions } from '@bugdump/sdk';
import { environment } from '../environments/environment';

@Injectable({ providedIn: 'root' })
export class BugReportService {
  private readonly bugdump = isPlatformBrowser(inject(PLATFORM_ID))
    ? Bugdump.init({ apiKey: environment.bugdumpKey, hideButton: true })
    : null;

  submit(options: SubmitOptions) {
    if (!this.bugdump) {
      return Promise.reject(new Error('Bug reports can only be sent from the browser.'));
    }
    return this.bugdump.submit(options);
  }
}

Then call it from the form in your own dialog:

TypeScript
import { Component, inject, output, signal } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { BugdumpApiError } from '@bugdump/sdk';
import { BugReportService } from './bug-report.service';

@Component({
  selector: 'app-report-form',
  imports: [ReactiveFormsModule],
  template: `
    <form [formGroup]="form" (ngSubmit)="send()">
      <textarea formControlName="description"></textarea>
      <input formControlName="email" type="email" />
      <input type="file" multiple (change)="onFiles($event)" />
      @if (error()) {
        <p role="alert">{{ error() }}</p>
      }
      <button type="submit" [disabled]="form.invalid || sending()">Send report</button>
    </form>
  `,
})
export class ReportFormComponent {
  private readonly reports = inject(BugReportService);
  private files: File[] = [];

  /** Emits the new task's number, so the dialog can close and thank the user. */
  readonly sent = output<number>();

  readonly form = inject(FormBuilder).nonNullable.group({
    description: ['', [Validators.required, Validators.maxLength(10000)]],
    email: ['', Validators.email],
  });
  readonly sending = signal(false);
  readonly error = signal('');

  onFiles(event: Event) {
    this.files = Array.from((event.target as HTMLInputElement).files ?? []);
  }

  async send() {
    const { description, email } = this.form.getRawValue();
    this.sending.set(true);
    this.error.set('');
    try {
      const { taskPublicId } = await this.reports.submit({
        description,
        reporterEmail: email || undefined,
        files: this.files,
      });
      this.sent.emit(taskPublicId);
    } catch (e) {
      this.error.set(e instanceof BugdumpApiError ? e.message : 'Could not send the report.');
    } finally {
      this.sending.set(false);
    }
  }
}

Script tag

submit() returns a promise, so it is not one of the queued bugdump('...') commands. Call it on the instance once the script has loaded:

JavaScript
async function sendReport(description) {
  const bugdump = window.Bugdump?.getInstance();
  if (!bugdump) throw new Error('BugDump has not loaded yet.');
  const { taskPublicId } = await bugdump.submit({ description });
  return taskPublicId;
}

This needs the SDK script to have loaded, so keep your send button disabled until window.Bugdump exists, or load the SDK from npm instead.

Errors and limits

submit() rejects with a BugdumpApiError. Its statusCode is the HTTP status and its code is the error code:

Status

Code

Meaning

400

VALIDATION_FAILED

A field is invalid — most often a malformed email or a description over 10,000 characters.

400

FILE_SIZE_TOO_BIG, FILE_NOT_ENOUGH_SPACE

A file is over your plan's per-report media size, or storage is full.

401

API_KEY_INVALID, API_KEY_EXPIRED

The widget key is wrong, revoked, or expired.

403

ORIGIN_NOT_ALLOWED

The page's origin is not in the project's allowed origins.

403

REPORT_QUOTA_EXCEEDED

The project has used its monthly widget reports. It accepts reports again at 00:00 UTC on the 1st.

403

TASK_LIMIT_REACHED

The project is on Free and holds 10,000 tasks.

429

RATE_LIMIT_EXCEEDED

Too many requests this minute.

500

REPORT_SUBMISSION_FAILED

The report could not be filed — including when taskId names a task that does not exist.

Neither REPORT_QUOTA_EXCEEDED nor TASK_LIMIT_REACHED is worth retrying. Tell the user that reporting is paused, as the widget does with "This site is not accepting feedback right now. Please try again later.":

TypeScript
if (e instanceof BugdumpApiError && (e.code === 'REPORT_QUOTA_EXCEEDED' || e.code === 'TASK_LIMIT_REACHED')) {
  setError('Reporting is paused right now. Please try again later.');
}

Every file is uploaded in its own request before the report is sent, and so is the session replay, so a report with two files is up to four requests. All of them count toward your project's per-minute limit — see Plans, limits, and usage. Disable your send button while submit() runs, so a double click does not file the report twice.

submit() only runs in a browser. In server-rendered code, call it from an event handler or a browser-only lifecycle hook — never during rendering.

Without the SDK

If you cannot load the SDK — a native app, a backend job — you can file a report with one HTTP request. You lose the automatic telemetry: the report contains only what you send.

Bash
curl -X POST https://api.bugdump.com/api/widget/v1/reports \
  -H "X-API-Key: your-widget-key" \
  -H "Content-Type: application/json" \
  -d '{"description": "Export to CSV times out", "reporterEmail": "jane@example.com", "priority": "High"}'

Only description is required. The body also accepts reporterName, reporterExternalId, taskPublicId, pageUrl, and customContext (any JSON object). A successful request returns 201 with { "id", "taskId", "taskPublicId" }. The same rate limits and monthly report limit apply, and requests from a browser must come from an allowed origin.

The widget key is public by design — it can only file reports. See Managing API keys safely.

See it working

The bugdump-custom-form-react repository is a small Vite + React app with its own dialog, a priority picker, and file attachments. Clone it, paste your widget key, and send yourself a report.

See also

Was this article helpful?