Heardfix API contract (v1, prototype)

Single source of truth between the Flutter plugin (flutter/), the web module (web/) and the Laravel package (repository root). Any change goes through a pull request that edits this file first; both sides follow.

Submit a report

POST {endpoint}/api/heardfix/v1/reports, multipart/form-data.

Headers

Header Required Value
X-Heardfix-App yes App key, generated by the back office when the app is created: hf_ followed by 32 letters and digits. Copy it from the app's page
Idempotency-Key yes The report id (UUID v4 generated on the device)
X-Heardfix-Altcha web_origin and public_form only Solved proof of work, see Web origin authentication
App supplied headers per app X-Firebase-AppCheck: <token>, from the plugin's appCheckToken callback. Any other header, e.g. Authorization: Bearer <token>, from its requestHeaders callback

The back office checks the headers against the authentication methods enabled for the app: firebase_app_check, sanctum, web_origin (websites), public_form (the public form of the app, see Public form authentication), key_only (mobile apps without Firebase, see Key only authentication), none (local, staging and testing environments only).

Parts

{
  "id": "6f1c2a4e-1b2c-4d5e-8f90-123456789abc",
  "kind": "bug",
  "title": "Sound does not start",
  "description": "I start a cardiac coherence session, the animation runs but no sound.",
  "email": "marie@example.com",
  "created_at": "2026-09-03T10:15:00+02:00",
  "trigger": "button",
  "context": {
    "app_version": "3.0.0",
    "build_number": "270",
    "platform": "android",
    "os_version": "Android 14",
    "device_model": "Samsung Galaxy A54",
    "locale": "fr-FR",
    "installation_id": "0b7e3c1d-5a6f-4b2e-9c8d-7e6f5a4b3c2d",
    "battery_percent": 64,
    "free_ram_mb": 812,
    "total_ram_mb": 3712,
    "free_disk_mb": 10240,
    "total_disk_mb": 119808,
    "app_installed_at": "2026-03-12T09:00:00+01:00",
    "app_updated_at": "2026-09-02T22:41:00+02:00",
    "app_started_at": "2026-09-03T10:02:00+02:00"
  },
  "diagnostics": {
    "logs": [
      { "at": "2026-09-03T10:14:58+02:00", "level": "info", "message": "Session started" }
    ],
    "errors": [
      { "at": "2026-09-03T10:14:59+02:00", "type": "PlatformException", "message": "Audio focus denied", "stacktrace": "#0 ..." }
    ],
    "sentry_event_ids": ["a1b2c3d4e5f60718293a4b5c6d7e8f90"],
    "crashed_previous_run": false
  }
}
Field Rule
id UUID v4, required
kind bug (the user reports a problem) or feature (the user suggests an improvement), required
title 3 to 120 characters, required
description up to 5000 characters, required
email valid email of at most 255 characters, or null
created_at ISO 8601 with offset, device clock
trigger button, shake or public_form. After a shake, the user picks the kind. Always button from the web module on a website, public_form from the public form of the app
context.platform android, ios or web
context.build_number, context.device_model required on android and ios; on web, null or absent
context.os_version on web, read from the browser, e.g. macOS or Android 14
context.installation_id UUID v4 generated once per install, also set as Sentry user.id and Crashlytics user identifier. The web module keeps nothing on the device: it sends a new UUID with each report
context.browser optional, at most 255 characters, e.g. Firefox 132
context.page_url optional, at most 2048 characters, the page address without query string or fragment, e.g. https://www.example.com/account. Any <scheme>:// address, so desktop and hybrid apps send theirs: file:///docs/index.md, app://bundle/index.html. javascript:, data: and blob: addresses are refused. The web module sends none for a file:// page opened without pageUrl: the local path may name the user of the computer
context.viewport optional, window size in CSS pixels, <width>x<height>, e.g. 1440x900
context.battery_percent optional, integer from 0 to 100, or null when the device does not tell (simulator)
context.free_ram_mb, context.total_ram_mb optional, integers in megabytes, or null when the device does not tell
context.free_disk_mb, context.total_disk_mb optional, integers in megabytes, or null when the device does not tell
context.app_installed_at optional, ISO 8601 with offset, or null. Android: first install of the app. iOS: creation of the app's documents directory
context.app_updated_at optional, ISO 8601 with offset, or null. Android: last update of the app, equal to app_installed_at when never updated. iOS: last modification of the app bundle
context.app_started_at optional, ISO 8601 with offset, or null. When the app started: the call to Heardfix.init in Flutter, the load of the page on the web
diagnostics.logs at most 200 entries, level in debug, info, warning, error, message at most 2000 characters
diagnostics.errors at most 20 entries, type at most 255 characters, message at most 2000 characters, stacktrace at most 20000 characters
diagnostics.sentry_event_ids at most 50 entries, each 32 lowercase hexadecimal characters
diagnostics.crashed_previous_run true, false, or null when unknown

Both kinds carry the same context and diagnostics: a triager may change the kind of a report once received.

The device fields (battery_percent to app_started_at) are measured when the report is created. They are optional, so a plugin that does not send them is still accepted.

Diagnostic texts never cause a rejection: the plugin truncates message and stacktrace to their limit before sending, and the server truncates type, message and stacktrace again when longer, keeping the beginning and appending [truncated]. An empty message is accepted. The user's fields (title, description, email) and the structure (entry counts, log levels, ISO 8601 dates) are still validated, and the plugin always sends a valid structure.

Responses

Status Body Plugin behaviour
201 {"id": "<uuid>", "status": "received"} Remove from the offline queue
200 Same body, report already received with this Idempotency-Key Remove from the queue
401, 403 {"message": "..."} Keep, retry later (token may refresh). Also the answer to a revoked app key: the report leaves with the new key once the app is updated
402 {"message": "...", "code": "reception_ceiling"}, the organization is on the Free plan and reached its reception ceiling Drop, log locally, tell the user if the form is still open
404 Unknown app key Drop, log locally
413 Payload or attachment too large Drop, log locally
422 {"message": "...", "errors": {"field": ["..."]}} Drop, log locally
429 Retry-After header in seconds. Also the answer to the reception ceiling of a paid plan, with "code": "reception_ceiling" in the body Keep, retry after the delay. Drop, log locally, when the delay outlasts the 7 days a report stays in the queue
5xx or network error Keep, retry with exponential backoff
Any other 4xx Drop, log locally

The web module has no queue: on any failure it keeps the form filled and shows an error. On 402, and on a 429 whose body carries "code": "reception_ceiling", the error tells the visitor to contact the developer of the site.

When the app starts, the plugin attempts every queued report right away, without waiting for its backoff, except a report answered 429. That one always waits until its delay has passed: Retry-After, or the backoff when the header is missing.

Rate limit: 5 reports per hour per installation_id, 20 per hour per IP. A report of platform web counts against 60 per hour per IP instead: its installation_id changes with each report, and a whole practice may share one IP. An app with the key_only or public_form method also has a daily report limit.

Servers accept requests of at least 32 MB (post_max_size, client_max_body_size) and files of at least 10 MB (upload_max_filesize).

Reception ceiling

An organization receives at most five times the quota of its plan: every report received counts, visible, hidden or deleted, over the last 30 days (since the start on Free). A new report beyond it is refused and nothing is stored, neither text nor attachment. Store reviews count but are never refused: the daily import is bounded by the stores. So that reviews alone never shut the apps out, a report sent by an app is refused only once the organization also received 10 reports sent by apps over the same window. A report already received is still answered 200, and the ceiling is checked before the daily report limit.

The Flutter plugin keeps the report on 429, unless Retry-After outlasts its queue, and drops it on 402. Both SDKs tell the user, when the form is still open: "Too many reports; contact the developer of the site."

Key only authentication

A mobile app without Firebase sends no authentication header: with the key_only method, the back office accepts any request carrying the app key, in every environment, as Sentry does with its public DSN.

The key ships inside the app, so anyone who extracts it can send reports in the app's name. Three limits contain it:

  1. The rate limits above, per installation and per IP.
  2. A daily report limit per app, set by the OwnWeb admin, 100 by default. Beyond it, a new report is answered 429 until midnight, time zone of the back office, and Retry-After gives the seconds left until then. A report already received is still answered 200. Store reviews do not count.
  3. The OwnWeb admin regenerates the app key. The old key is answered 401 right away: the versions of the app that carry it can no longer send, and keep their reports until an update brings the new key.

Regenerating the key works for any app, whatever its methods.

Web origin authentication

An app with the web_origin method lists its allowed origins in the back office: scheme, host and port, no wildcard (https://www.example.com). A request is accepted when both hold:

  1. Its Origin header is one of the app's origins. Browsers set it and scripts in a page cannot change it.
  2. Its X-Heardfix-Altcha header carries a solved ALTCHA challenge, base64 encoded JSON {"algorithm", "challenge", "number", "salt", "signature"}. A challenge expires after 5 minutes and is accepted once.

GET {endpoint}/api/heardfix/v1/challenge returns a challenge, {"algorithm": "SHA-256", "challenge": "...", "maxnumber": 50000, "salt": "...", "signature": "..."}. The browser finds the number whose SHA-256 of salt followed by number equals challenge, in well under a second. The route sends Access-Control-Allow-Origin: *, and is limited to 60 requests per minute per IP.

CORS on POST /api/heardfix/v1/reports: the preflight OPTIONS request is answered 204 with Access-Control-Allow-Headers: X-Heardfix-App, Idempotency-Key, X-Heardfix-Altcha. The response to the POST carries Access-Control-Allow-Origin only when the Origin is one of the app's origins, or one of the origins of its public form when the app has the public_form method.

Public form authentication

The public form is a page of the back office where anyone sends a report for an app, without the app or its website: GET {endpoint}/heardfix/feedback/{slug}, and /feedback of the roadmap domain of the app. It exists while the app has the public_form method, and sends its reports with the web module, platform web, trigger public_form, without page_url, screenshot nor errors. Its app_version is unknown: the back office does not keep it, since the form has no version of its own.

A request is accepted when both hold:

  1. Its Origin header is the origin of the back office, or https:// followed by the roadmap domain of the app when its plan allows that domain.
  2. Its X-Heardfix-Altcha header carries a solved challenge, as for Web origin authentication.

The page is public, so the app also has the daily report limit of the key_only method. It counts every report of the app, whatever the method that accepted it.

Back office only

These routes are not used by the plugin and may change freely: