REST API

Two surfaces: a read API under /api/v1 authenticated with an API key, and the public write endpoint /api/ingest that the SDK posts to. Base URL on this instance is https://betterloopai.com.

Authentication

Every /api/v1 request needs an API key created on the API keys page, sent as a Bearer token. Keys start with blk_, are shown once at creation, and are stored only as a SHA-256 hash. A key is scoped to the account that created it and reaches every project on that account.

curl -s "https://betterloopai.com/api/v1/projects" \
  -H "Authorization: Bearer blk_your_api_key"

Do not confuse the two key types. The fp_ write key is public and only allows writing events for one project. The blk_ API key is secret and reads your data - keep it server-side.

Common errors

StatusBodyMeaning
401{"error":"unauthorized"}Missing header, wrong prefix, or a revoked key.
400{"error":"projectId required"}The projectId query parameter is absent.
404{"error":"not_found"}The project exists but is not owned by this key's account, or does not exist at all. The two cases are deliberately indistinguishable.

Projects

GET/api/v1/projects

Every project on the account. No parameters. Use these ids with the other endpoints.

Response

{
  "projects": [
    {
      "id": "3f0a51c8-6b2e-4a7d-9c31-0b6f2a4e77d1",
      "name": "Acme Storefront",
      "createdAt": "2026-07-01T09:14:22.118Z"
    }
  ]
}

Insights

GET/api/v1/insights
ParameterDefaultNotes
projectIdrequiredFrom /api/v1/projects.
statusnewnew, ack, done, dismissed, or all. An unknown value simply matches nothing.

Returns 50 insights by default, sorted by score descending and then newest first - pass limit (1-100) for a different page size. When more exist than were returned, the response carries hasMore: true, so a truncated page is never mistaken for the whole list. Field meanings are documented in How insights work.

curl -s "https://betterloopai.com/api/v1/insights?projectId=$PROJECT_ID&status=new" \
  -H "Authorization: Bearer blk_your_api_key"

Response

{
  "insights": [
    {
      "id": "b41c0a8e-3d7f-4a12-9f88-2c6e5b90ad34",
      "category": "bug",
      "severity": 3,
      "title": "JS error hitting 9 sessions: Cannot read properties of undefined",
      "body": "\"Cannot read properties of undefined (reading 'total')\" fired 31 times across 9 sessions in the last 7 days (sample page: /checkout).",
      "path": "/checkout",
      "evidence": {
        "message": "Cannot read properties of undefined (reading 'total')",
        "occurrences": 31,
        "sessions": 9
      },
      "status": "new",
      "source": "rules",
      "createdAt": "2026-07-23T02:11:07.442Z"
    },
    {
      "id": "5d9e17b0-84c2-4f61-b0a7-91f3c2d84e05",
      "category": "ux",
      "severity": 2,
      "title": "Users rage-click \"Save changes\" on /settings",
      "body": "6 different sessions rapidly clicked the same element (main>form>button.btn.primary) 22 times total in the last 7 days.",
      "path": "/settings",
      "evidence": {
        "selector": "main>form>button.btn.primary",
        "clicks": 22,
        "sessions": 6,
        "windowDays": 7
      },
      "status": "new",
      "source": "rules",
      "createdAt": "2026-07-23T02:11:07.451Z"
    }
  ]
}

Metrics

GET/api/v1/metrics
ParameterDefaultNotes
projectIdrequiredFrom /api/v1/projects.
days7Lookback window in days, clamped to 1 to 90.

topPages returns up to 20 rows. webVitalsP75 has one row per metric the SDK actually captured, with the sample count behind it - a p75 over a handful of samples is not worth acting on. daily only contains days that had at least one event.

curl -s "https://betterloopai.com/api/v1/metrics?projectId=$PROJECT_ID&days=7" \
  -H "Authorization: Bearer blk_your_api_key"

Response

{
  "days": 7,
  "stats": {
    "pageviews": 18420,
    "sessions": 5231,
    "errors": 96,
    "rageClicks": 41,
    "deadClicks": 63,
    "total": 74310
  },
  "topPages": [
    { "path": "/", "views": 7210, "sessions": 3980 },
    { "path": "/pricing", "views": 3102, "sessions": 2245 }
  ],
  "webVitalsP75": [
    { "name": "LCP", "p75": 2410, "samples": 4802 },
    { "name": "INP", "p75": 184, "samples": 2911 },
    { "name": "CLS", "p75": 0.06, "samples": 3760 },
    { "name": "TTFB", "p75": 288, "samples": 5102 },
    { "name": "FCP", "p75": 1290, "samples": 4655 }
  ],
  "daily": [
    { "day": "2026-07-18", "pageviews": 2510, "sessions": 742, "errors": 11 },
    { "day": "2026-07-19", "pageviews": 2688, "sessions": 761, "errors": 9 }
  ]
}

Counting notes. sessions counts distinct session ids across all event types in the window, so it is higher than the number of sessions that produced a pageview. errors combines error and rejection events, and total is every stored event including clicks and vitals.

Errors

GET/api/v1/errors
ParameterDefaultNotes
projectIdrequiredFrom /api/v1/projects.
days30Lookback window in days, clamped to 1 to 90. Note the different default from /metrics.

50 groups by default, keyed on the error message and sorted by occurrence count. Takes the same limit (1-100) and returns hasMore the same way. samplePath and sampleStack are one representative example from the group, not the only one. Unlike the insight detector, this endpoint also includes resource load failures, which appear with msg: "resource_error".

curl -s "https://betterloopai.com/api/v1/errors?projectId=$PROJECT_ID&days=30" \
  -H "Authorization: Bearer blk_your_api_key"

Response

{
  "days": 30,
  "errors": [
    {
      "msg": "Cannot read properties of undefined (reading 'total')",
      "count": 31,
      "sessions": 9,
      "lastSeen": "2026-07-24 08:12:44.201+00",
      "samplePath": "/checkout",
      "sampleStack": "TypeError: Cannot read properties of undefined (reading 'total')\n    at CartSummary (app/checkout/CartSummary.tsx:41:19)"
    },
    {
      "msg": "resource_error",
      "count": 12,
      "sessions": 7,
      "lastSeen": "2026-07-23 19:02:11.884+00",
      "samplePath": "/",
      "sampleStack": null
    }
  ]
}

lastSeen is a Postgres timestamp string with a space separator and a UTC offset, not an ISO-8601 T string. Parse it accordingly.

Signals: what connected sources reported

Everything a connected source measures is normalized to one shape, which is what lets a single recommendation draw on two of them. There is no REST endpoint for these yet - they are exposed through the MCP get_signals tool, documented below. The shape is the same either way:

{
  "source":     "site_audit",        // which connector produced it
  "domain":     "security",          // which advice domain it feeds
  "kind":       "site.header.csp",   // stable machine key - safe to join on across syncs
  "label":      "No Content-Security-Policy",
  "value":      0,                   // null when the observation is not numeric
  "unit":       "bool",              // ms | s | % | count | usd | score | bool | null
  "target":     "https://your.site/",// the url, path, file, repo or plan it concerns
  "severity":   2,                   // 1-3, only when the source itself judged it
  "detail":     "No Content-Security-Policy header or meta tag was returned, so ...",
  "observedAt": "2026-07-25T12:40:11.000Z"
}

Two properties are worth relying on. kind is stable across syncs and contains no numbers or dates, so it is a safe join key for your own trend tracking. And the table is append-only for 90 days, so the newest row per (source, kind, target) is the current state while the ones behind it are the history our regression detector reads.

The MCP endpoint

POST https://betterloopai.com/api/mcp speaks the Model Context Protocol over Streamable HTTP, so an agent can read the same data without you writing a client. It takes the same Authorization: Bearer key as v1, and every tool re-checks that the project belongs to that key. Full client setup is in the MCP docs; the wire format, if you want to call it directly:

curl -s https://betterloopai.com/api/mcp \
  -H "Authorization: Bearer blk_your_api_key" \
  -H "Content-Type: application/json" \
  -H "Accept: application/json, text/event-stream" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
       "params":{"name":"get_signals",
                 "arguments":{"projectId":"...","domain":"security"}}}'

Five tools: list_projects, get_insights, get_metrics, get_errors and get_signals. It is stateless - no session id is issued, so any request can go to any replica and nothing expires. It answers POST only and replies 405 to GET, because it opens no server-initiated stream. Requests are limited to 60 per minute per key, answered as 429 with a Retry-After header.

What v1 does not do

There are no write endpoints. Changing an insight status, creating projects, editing settings, and revoking keys all happen in the dashboard, and no API key can perform them - the MCP endpoint is read-only for the same reason. There is no pagination yet either: each endpoint returns a capped, sorted top slice. And signals have no REST route yet, only the MCP tool. If you need any of that, say so - each is a small change, and knowing someone needs it is what decides the order.

The ingest endpoint

This is the endpoint the browser SDK posts to. You rarely call it by hand, but it is a plain documented HTTP contract, so you can send events from a platform the SDK does not cover. Authentication is the project write key inside the body - there is no Bearer token here.

POST/api/ingest
FieldTypeConstraint
keystringRequired. Must start with fp_, max 64 chars.
sidstringRequired. Session id, max 64 chars. Sessions are counted on this value.
urlstringOptional origin of the page, max 300 chars.
refstringOptional referrer, max 500 chars.
vw, vhintegerOptional viewport width and height.
eventsarrayRequired. 1 to 100 items.
events[].tenumpageview, click, rage_click, dead_click, error, rejection, vital, custom, session_end.
events[].tsintegerUnix milliseconds. Clamped server-side to the window from one hour before to one minute after server time, so backfilling history is not possible.
events[].pathstringRequired, max 500 chars. Empty falls back to /.
events[].attrsobjectOptional. Must serialize to 8192 bytes or less, or the whole batch is rejected.
curl -X POST "https://betterloopai.com/api/ingest" \
  -H "Content-Type: application/json" \
  -d '{
    "key": "bl_your_write_key",
    "sid": "manual-smoke-test",
    "url": "https://example.com",
    "vw": 1440,
    "vh": 900,
    "events": [
      { "t": "pageview", "ts": 1753344000000, "path": "/", "attrs": { "title": "Home" } }
    ]
  }'

Response

{ "ok": true }

Ingest status codes

StatusBodyCause
200{"ok":true}Batch stored.
400{"error":"bad_json"}The body was not valid JSON.
400{"error":"invalid_payload"}A field broke one of the constraints above. The whole batch is dropped, never partially stored.
401{"error":"unknown_key"}No project has this write key, or the project was deleted.
402{"error":"quota_exceeded"}The project passed its monthly event limit. The counter and the insert happen in one transaction, so a rejected batch never inflates your usage.
403{"error":"origin_not_allowed"}The project has an allowlist and the request's Origin header is missing or not on it. A missing header is rejected too, so non-browser clients cannot slip past the allowlist.
429{"error":"rate_limited"}More than 120 batches in 60 seconds from one IP.

CORS: the endpoint answers preflights with 204 and allows any origin with a Content-Type header, because analytics has to work from every domain a customer owns. Restricting who may write for your project is what the per-project origin allowlist in Settings is for.

Project lookups are cached for 15 seconds, so a new allowlist or a plan change can take that long to take effect.