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
payload: JSON document, below.attachments[]: up to 3 images (image/jpeg,image/png,image/heic), 10 MB each. The server re-encodes them to strip EXIF metadata, GPS included. The server also stores every image as WebP (image/webp), whatever the client sent. An image whose longer side exceeds 2560 px is scaled down to 2560 px, proportions kept; a smaller image keeps its size. A PNG (screenshot) is encoded lossless and lossy at quality 70, and the lighter file is kept. A JPEG or HEIC (photo) is encoded lossy at quality 70. Attachments stored before this rule keep their format.
{
"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.
- Paid plan:
429withRetry-After, the seconds until enough reports leave the 30 days for a place to free up, and"code": "reception_ceiling"in the body. - Free plan:
402with"code": "reception_ceiling". No place ever frees up, only a plan change raises the ceiling, at once.
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:
- The rate limits above, per installation and per IP.
- A daily report limit per app, set by the OwnWeb admin, 100 by default. Beyond it, a new report is answered
429until midnight, time zone of the back office, andRetry-Aftergives the seconds left until then. A report already received is still answered200. Store reviews do not count. - The OwnWeb admin regenerates the app key. The old key is answered
401right 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:
- Its
Originheader is one of the app's origins. Browsers set it and scripts in a page cannot change it. - Its
X-Heardfix-Altchaheader 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:
- Its
Originheader is the origin of the back office, orhttps://followed by the roadmap domain of the app when its plan allows that domain. - Its
X-Heardfix-Altchaheader 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:
GET|POST /heardfix/reply/{token}: signed reply page linked from emails, valid 30 days.POST /api/heardfix/v1/clickup/webhook/{organization}: ClickUp task status webhook of one organization,X-SignatureHMAC checked.HEAD|POST /api/heardfix/v1/trello/webhook/{organization}: Trello card webhook of one organization.HEADanswers200, as Trello checks the address before creating the webhook.POSTchecks theX-Trello-WebhookHMAC.GET /heardfix/trello/callback: return of the Trello OAuth 2.0 consent, with thecodeandstateAtlassian adds. Only accepted from the session that started the connection.