Overview
FixNoty API v1 is a versioned, read-only HTTP interface for exporting every report that belongs to a single project. It is designed for scripts, CI, internal tools, and later automations — including an AI agent that needs the same solving context a human sees in the dashboard.
The API never changes status, never writes comments, never deletes objects, and never returns raw storage paths or secret material. Media is delivered as time-bounded signed URLs. Session replay is delivered either as rrweb event chunks or as a 24-hour signed link to the same interactive player used in the dashboard Replay tab — not as a newly encoded MP4.
- Protocol: HTTPS JSON. Request bodies are not used (all methods are GET).
- Prefix: /api/v1. Locale prefixes such as /en or /it are for the website only; they are not part of the API path.
- Scope: one project per key. Cross-project IDs return 404 with code not_found.
- Compatibility: status, priority, and category enums are the Italian database strings (for example aperto). Do not translate them in clients.
Base URL and versioning
Production host: https://www.fixnoty.com. Replace with your deployment origin in development. Concatenate the prefix without a trailing slash:
http
https://www.fixnoty.com/api/v1/feedbackBreaking changes ship under a new prefix (/api/v2). Additive JSON fields may appear in v1 without a version bump; clients must ignore unknown keys. There is no OpenAPI document in v1.
Authentication
Authenticate with a project API key. The key is scoped to exactly one project and grants read access only. It authenticates the project, not a user: anyone who holds the secret (a script, a collaborator, an integration) can read every report in that project.
Send the secret with exactly one of these headers:
http
Authorization: Bearer fn_live_…
X-Api-Key: fn_live_…If both are present, Authorization: Bearer wins. Query-string keys, cookies, and request bodies are ignored.
| Field | Type | Description |
|---|---|---|
| Format | string | fn_live_ + 64 lowercase hex characters in production, or fn_test_ + 64 hex in development. Regex: ^fn_(live|test)_[a-f0-9]{64}$. |
| Storage | sha256 | Only the SHA-256 digest of the secret is stored. The plaintext is shown once in Project settings → API and cannot be recovered. |
| Prefix | string | The dashboard lists fn_live_ / fn_test_ plus the first 8 hex characters so you can recognize a key without the secret. |
| Lifecycle | owner | API keys are included in the Agency plan (and demo accounts). Trial and Pro cannot create keys; existing keys return 403 plan_required. Only the project owner can create or revoke keys. At most 20 active keys per project. Revocation is immediate: subsequent requests return 401 invalid_api_key, and previously minted 24h replay player links bound to that key stop working. |
| last_used_at | datetime | Updated at most once per minute after a successful request. A missing value means the key has never been used successfully. |
Errors
Failed responses use a stable JSON body. code is always English and is the field you should switch on. error is a human message and may be localized from Accept-Language (it or fallback en).
json
{
"error": "Invalid or revoked API key.",
"code": "invalid_api_key"
}| Field | Type | Description |
|---|---|---|
| 401 missing_api_key | auth | Neither Authorization: Bearer nor X-Api-Key was sent. |
| 401 invalid_api_key | auth | Malformed, unknown, or revoked key. Same code in all three cases so attackers cannot probe key existence. |
| 400 invalid_request | query | Illegal query (status, feedback_type, updated_since, limit, or cursor). |
| 404 not_found | scope | The project or report does not exist, the id is not a UUID, or it belongs to another project. Cross-project probes are indistinguishable from missing rows. |
| 429 rate_limited | throttle | Too many requests. Retry-After is set to seconds until the sliding window has capacity. |
| 403 plan_required | plan | The project owner's plan does not include the API. Agency (and demo) does; trial and Pro do not. |
| 503 database_unavailable | infra | The API cannot reach the configured database. |
A malformed replay-player token, an expired token, or a token whose API key was revoked is not a v1 error: the watch page and its event stream respond with 404 or 410 and are not part of the versioned contract.
Rate limits
Sliding 60-second windows, counted in process memory per server instance (MVP; not a global Redis quota). Both limits apply:
- 60 requests / minute / client IP, evaluated before the key is parsed. Unauthenticated callers still consume this budget.
- 120 requests / minute / authenticated key, evaluated after a successful key lookup.
Exceeding either limit returns 429 with code rate_limited and a Retry-After header (seconds, minimum 1). API access is included with Agency, not sold as a separate plan.
Headers and caching
Every v1 JSON response includes:
http
Cache-Control: private, no-storeSigned media URLs and player links must not be cached by CDNs or shared HTTP caches. Clients may keep a response in local memory until the enclosed expires_at and must call GET /feedback/:id/assets (or /replay) to mint a fresh URL.
Secrets are never written to application logs. Do not log Authorization, X-Api-Key, player tokens, or signed URL query strings.
Cursor pagination
GET /feedback uses keyset pagination on (updated_at DESC, id DESC). It does not use SQL OFFSET.
- Default page size 50; maximum 100. Values above the maximum are clamped, not rejected.
- When another page exists, has_more is true and next_cursor is an opaque base64url blob. Pass it back as cursor.
- A malformed cursor yields 400 invalid_request. Do not invent cursors; they are not a public query language.
Signed assets
Binary objects are never inlined in JSON and the API never returns a project zip. Each file is an object:
json
{
"url": "https://…/object/sign/…?token=…",
"expires_in": 1800,
"expires_at": "2026-10-02T10:30:00.000Z"
}| Field | Type | Description |
|---|---|---|
| screenshot, widget attachments, debug attachments | TTL 1800s | 30 minutes from the moment the response is minted. |
| rrweb manifest + chunks, 24h player watch URL | TTL 86400s | 24 hours. Reuses objects already stored for the dashboard player; no extra encode step. |
GET the url directly. After expiry the object store rejects the token; call assets or replay again. Attachment rows also include id, file_name, mime_type, file_size, and created_at.
Session replay
Native capture is rrweb DOM events, not a screen recording. v1 exposes two first-class formats plus optional legacy video:
- formats.player — a signed HTML page that mounts the same rrweb player as the dashboard Replay tab. Valid 24 hours. Zero extra storage.
- formats.rrweb — signed JSON manifest + ordered chunk files (24 hours). Concatenate chunk event arrays in order and feed them to any rrweb player.
- formats.video — present only when a historical MP4 already exists on the row. v1 does not transcode rrweb to MP4. Typical status: not_generated.
formats.still is the report screenshot (30-minute TTL). timeline is the widget action log (clicks, navigation, masked input, JS errors) — a separate, smaller JSON array from rrweb.
Reports without a recording still return HTTP 200 with has_replay: false. HTTP 404 means the report is not in the key’s project.
24-hour player security
- Issued exclusively by authenticated GET /api/v1/feedback/:id and …/replay for a report in the same project as the key.
- HMAC-SHA256 over an expiry-bearing payload. Tokens are opaque; clients must not parse them.
- Claims include project id, report id, API key id, and Unix expiry. Revoking the key invalidates every outstanding player URL that was minted with it, even before the 24-hour TTL.
- Expired, forged, or revoked tokens → 404/410. The watch page does not require a dashboard login (so a browser or a browser-capable agent can open it). Possession of the URL is the credential until expiry or revocation.
- Response headers: Cache-Control: private, no-store, Referrer-Policy: no-referrer, X-Frame-Options: DENY, Content-Security-Policy: frame-ancestors 'none', X-Robots-Tag: noindex, nofollow, X-Content-Type-Options: nosniff. The player cannot be embedded in a third-party iframe and is not offered to search engines.
The HTML player loads events from an internal GET /api/replay-watch/:token stream (IP-throttled, same security headers). That route is not part of the v1 contract; integrate against formats.player.watch.url or formats.rrweb.
GET /api/v1/project
Confirms the key and returns the project it is bound to. Use this as a connectivity check before listing reports.
json
{
"project": {
"id": "11111111-2222-4333-8444-555555555555",
"name": "Client site",
"site_url": "https://staging.example.com"
}
}| Field | Type | Description |
|---|---|---|
| project.id | uuid | Project bound to the key. |
| project.name | string | Display name. |
| project.site_url | string | Configured site URL. |
GET /api/v1/feedback
Lightweight index of reports in the project. It never includes recording_events, console_logs, conversation, activity, or signed file URLs. Use detail / assets / replay for those.
Query parameters
| Field | Type | Description |
|---|---|---|
| status | string | Default open. open or aperte = aperto + in_lavorazione + in_attesa_cliente. all or tutti = every status. Otherwise a comma-separated list of database statuses. |
| feedback_type | enum | visual | debug_replay | text_content | request |
| updated_since | ISO 8601 | Inclusive lower bound on updated_at. Invalid timestamps → 400. |
| limit | int | 1–100, default 50. Non-integers and values < 1 → 400. |
| cursor | opaque | next_cursor from the previous page. |
| include | ignored | Parsed but heavy JSONB (recording_events) is never dumped on the list endpoint. |
json
{
"data": [
{
"id": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
"pin_number": 4,
"status": "aperto",
"priority": "bloccante",
"category": "bug",
"feedback_type": "debug_replay",
"title": "Checkout does not submit the order",
"comment": "I click Pay and nothing happens.",
"client_title": null,
"page_url": "https://staging.example.com/checkout",
"page_path": "/checkout",
"created_at": "2026-10-01T14:12:00.000Z",
"updated_at": "2026-10-01T15:40:00.000Z",
"last_activity_at": "2026-10-01T15:40:00.000Z",
"author_name": "Anna",
"assigned_to": null,
"has_screenshot": true,
"has_replay": true,
"has_attachments": true,
"has_console_errors": true,
"attachment_count": 1
}
],
"next_cursor": null,
"has_more": false
}| Field | Type | Description |
|---|---|---|
| id | uuid | Report id. |
| pin_number | number | Pin shown on the page. |
| status | enum | aperto | in_lavorazione | in_attesa_cliente | risolto | archiviato |
| priority | enum | normale | importante | bloccante |
| category | enum | testo | design | immagine | bug | mobile | contenuto | altro |
| feedback_type | enum | visual | debug_replay | text_content | request |
| title | string | null | Dashboard title. |
| comment | string | Client comment. |
| client_title | string | null | Title of a “new request” brief. |
| page_url | string | Full page URL at capture. |
| page_path | string | Pathname derived from page_url (query and hash stripped). |
| created_at / updated_at | datetime | UTC ISO 8601. updated_at drives the cursor. |
| last_activity_at | datetime | null | Last dashboard/portal activity. |
| author_name | string | Client display name. |
| assigned_to | object | null | { user_id, name, email } when assigned. |
| has_screenshot / has_replay / has_attachments / has_console_errors | boolean | Cheap flags for routing to detail/replay. |
| attachment_count | number | Widget attachment count (not debug files). |
| next_cursor | string | null | Opaque cursor or null on the last page. |
| has_more | boolean | Whether another page exists. |
GET /api/v1/feedback/:id
Full solving context for one report in the key’s project. Optional query include=recording_events adds the widget timeline. rrweb events are never inlined; use the signed URLs under replay.
http
GET /api/v1/feedback/aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee?include=recording_events
Authorization: Bearer $FIXNOTY_API_KEYIdentity
| Field | Type | Description |
|---|---|---|
| id | uuid | Report id. |
| project_id | uuid | Always the project bound to the key. |
| pin_number | number | Pin number. |
| status / priority / category / feedback_type | enum | Same enumerations as the list endpoint. |
| created_at / updated_at / last_activity_at | datetime | UTC timestamps. |
| resolved_at / archived_at | datetime | null | Set when the report left the open set. |
Text / brief
| Field | Type | Description |
|---|---|---|
| title / comment | string | Title may be null; comment is the primary client text. |
| user_goal / actual_result / expected_result | string | null | Structured bug fields when the client filled them. |
| reproducibility / impact | string | null | Optional severity context. |
| affected_devices | string[] | null | Devices the client mentioned. |
| client_title / client_description | string | null | “New request” brief. |
| waiting_for_customer_reason | string | null | Why the report is waiting on the client. |
Location
| Field | Type | Description |
|---|---|---|
| page_url / page_path | string | Where the pin was placed. |
| position | object | { x, y, x_percent, y_percent } click/pin coordinates. |
| selection | object | { type: point | area | none, rect_*_percent } selected rectangle. |
| scroll | object | { x, y } scroll offsets. |
| viewport | object | { width, height } layout viewport at capture. |
Environment
| Field | Type | Description |
|---|---|---|
| environment.user_agent | string | null | Raw UA string. |
| environment.browser / device | string | null | Parsed browser and device class. |
| environment.recording_metadata | object | null | viewport_width/height, visual_viewport_*, device_pixel_ratio, orientation, screen_*, touch_support, pointer_events, device_class, platform, pages_visited, event_count, replay_segment_count, session_id. Secrets stripped. |
People
| Field | Type | Description |
|---|---|---|
| author | object | { name, email }. Email may be null. |
| assigned_to | object | null | { user_id, name, email }. |
| visible_to_client | boolean | Whether the client portal shows this report. |
| client_confirmed_at / client_reopened_at | datetime | null | Portal confirmation / reopen. |
Media
| Field | Type | Description |
|---|---|---|
| screenshot | signed | null | 30-minute signed JPEG/PNG/WebP. screenshot_captured_at is when it was taken. |
| attachments[] | array | Widget files with signed URLs (30 min). |
| debug_attachments[] | array | Files captured during Record issue (30 min). |
| replay | object | null | See replay package. playback is always “rrweb”. video is an alias of formats.video.file when a legacy MP4 exists. |
Debug, conversation, flags
| Field | Type | Description |
|---|---|---|
| console_logs | array | { type: error | unhandledrejection, message, stack?, url?, timestamp_ms }. |
| recording_events | array | null | Widget timeline. null unless include=recording_events. |
| recording_warnings | array | { code, message } from capture. |
| debug_summary / captured_values | object | array | null | Sanitized advanced-debug payload. Sensitive keys are stripped recursively. |
| conversation[] | array | Team notes + client comments, chronological. { id, body, created_at, visibility: internal | client, source: team | client, author_name }. |
| activity[] | array | { id, action, old_value, new_value, created_at }. action is one of the activity enumerations below. created, status_changed, priority_changed, category_changed, internal_notes_updated, customer_update_added, waiting_reason_updated, feedback_resolved, feedback_archived, client_comment_added, client_confirmed_resolution, client_reopened_task, visibility_changed, screenshot_captured, replay_attached, file_attached, client_context_added, title_updated, assignee_changed. |
| flags | object | has_screenshot, has_replay, has_attachments, has_console_errors, has_advanced_data, has_debug_files. |
GET /api/v1/feedback/:id/assets
Re-mints signed URLs without re-downloading comments, conversation, or activity. Top-level expires_in is the screenshot/attachment TTL (1800s). replay_expires_in is the player/rrweb TTL (86400s).
json
{
"data": {
"screenshot": {
"url": "https://….supabase.co/storage/v1/object/sign/feedback-screenshots/…?token=redacted",
"expires_in": 1800,
"expires_at": "2026-10-02T10:30:00.000Z"
},
"attachments": [],
"debug_attachments": [],
"replay": null
},
"expires_in": 1800,
"replay_expires_in": 86400
}GET /api/v1/feedback/:id/replay
Dedicated replay envelope. Widget timeline is included by default. Open formats.player.watch.url in a browser to see the session exactly as in the dashboard.
json
{
"data": {
"feedback_id": "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee",
"has_replay": true,
"duration_seconds": 42,
"recording_session_id": null,
"debug_session_id": "bbbbbbbb-cccc-4ddd-8eee-ffffffffffff",
"expires_in": 86400,
"formats": {
"rrweb": {
"available": true,
"playback": "rrweb",
"expires_in": 86400,
"manifest": {
"url": "https://….supabase.co/storage/v1/object/sign/debug-replays/…?token=redacted",
"expires_in": 86400,
"expires_at": "2026-10-03T10:00:00.000Z"
},
"chunks": [
{
"url": "https://…/chunk-000.json?token=redacted",
"expires_in": 86400,
"expires_at": "2026-10-03T10:00:00.000Z"
}
]
},
"player": {
"available": true,
"playback": "player",
"expires_in": 86400,
"watch": {
"url": "https://www.fixnoty.com/it/replay/<signed-token>",
"expires_in": 86400,
"expires_at": "2026-10-03T10:00:00.000Z"
}
},
"video": {
"available": false,
"playback": "mp4",
"status": "not_generated",
"mime_type": "video/mp4",
"expires_in": 86400,
"file": null
},
"still": {
"url": "https://….supabase.co/storage/v1/object/sign/feedback-screenshots/…?token=redacted",
"expires_in": 1800,
"expires_at": "2026-10-02T10:30:00.000Z"
}
},
"timeline": [
{
"t": 1200,
"at": "2026-10-01T14:12:01.200Z",
"type": "click",
"page_url": "https://staging.example.com/checkout"
}
],
"warnings": []
},
"expires_in": 86400
}Using rrweb chunks
- GET the signed manifest URL.
- GET each chunk URL in array order.
- Concatenate the event arrays.
- Pass the result to an rrweb player. This is not an MP4.
Enumerations
status
aperto | in_lavorazione | in_attesa_cliente | risolto | archiviato
priority
normale | importante | bloccante
category
testo | design | immagine | bug | mobile | contenuto | altro
feedback_type
visual | debug_replay | text_content | request
recording event type
widget_loaded · widget_reconnected · click · tap · pointer_down · pointer_up · scroll · navigation · input_focus · input_change_masked · password_field_interaction_masked · keydown_masked · file_selection_masked · console_error · js_error · unhandled_rejection · visibility_change · pagehide · beforeunload · debug_started · debug_stopped · replay_started · replay_stopped · replay_snapshot · input_value_captured · input_value_masked · dropdown_open · dropdown_selection · picker_open · picker_selection · file_selected · file_attached · file_attachment_skipped · orientation_change · viewport_resize · visual_viewport_resize · tab_open
Timeline items: { t, at, type, page_url?, from?, to?, x?, y?, … }. Password and sensitive inputs are masked at capture (*_masked types).
Consuming with an AI system
This API is a data channel. It does not call a model and does not auto-fix reports.
- Text-only LLM. Read list + detail JSON, console logs, timeline, and conversation. You get actions, errors, copy, and one still frame. You do not get motion.
- Browser-capable agent. Open formats.player.watch.url, press play, and screenshot or watch the reconstructed DOM. A bare HTTP GET of that URL returns the player HTML/JS shell, not video frames.
- Custom player. Download formats.rrweb chunks. Same events the dashboard uses.
We do not generate MP4 from rrweb: it would duplicate storage already held as event chunks. The 24-hour player reuses those chunks.
Security model
- This documentation URL is public on purpose. Public docs describe the contract. They do not grant access.
- Access control is the project API key (hashed at rest) plus server-side project_id filters. Guessing another project’s report UUID cannot leak it.
- Dashboard RLS: only the owner can select/insert/update rows in project_api_keys.
- Player links are unlisted, unindexed, non-embeddable, time-bounded, and key-revocable. Do not forward them.
- Do not put API keys in frontend bundles or in Referer-leaking pages. Prefer server-side callers.
curl examples
bash
export FIXNOTY_API_KEY='fn_live_…'
export APP_URL='https://www.fixnoty.com'
# Sanity-check the key
curl -sS "$APP_URL/api/v1/project" \
-H "Authorization: Bearer $FIXNOTY_API_KEY"
# Open reports (default)
curl -sS "$APP_URL/api/v1/feedback?status=open&limit=50" \
-H "Authorization: Bearer $FIXNOTY_API_KEY"
# Every status since 1 Oct 2026
curl -sS "$APP_URL/api/v1/feedback?status=all&updated_since=2026-10-01T00:00:00.000Z" \
-H "Authorization: Bearer $FIXNOTY_API_KEY"
# Full detail + widget timeline
curl -sS "$APP_URL/api/v1/feedback/FEEDBACK_ID?include=recording_events" \
-H "Authorization: Bearer $FIXNOTY_API_KEY"
# Refresh signed media only
curl -sS "$APP_URL/api/v1/feedback/FEEDBACK_ID/assets" \
-H "X-Api-Key: $FIXNOTY_API_KEY"
# Replay package: 24h player + rrweb + still + timeline
curl -sS "$APP_URL/api/v1/feedback/FEEDBACK_ID/replay" \
-H "Authorization: Bearer $FIXNOTY_API_KEY"Versioning and out of scope
Current version: v1. Additive fields may appear. The following are explicitly out of v1:
- POST / PATCH / DELETE (status, comments, assignment)
- Synchronous zip of a whole project
- Billing, paid rate plans, webhooks
- Official SDKs and OpenAPI
- Model inference, auto-fix, or outbound AI webhooks
- MP4 transcode from rrweb
Canonical public reference: this page. A matching technical note lives in the repository at docs/api-v1.md. Browser “Print → Save as PDF” is supported; we do not ship a separately versioned PDF that can drift from the implementation.