---
title: "Troubleshooting"
description: "When CanaryLytics shows no data, start with GET /v1/validate. It dry-runs the exact ingest acceptance rules and explains every silent drop your key is allowed to see."
last_updated: "2026-07-30"
---

# Troubleshooting

`POST /ingest` always answers `204`, whether an event was accepted or dropped, so there is no error to read in the Network tab. Start every "no data showing up" investigation with `GET /v1/validate`.

## Common silent-drop causes, ranked

1. `data-domain` doesn't match any trusted domain (typo, or the domain was never added).
2. Page origin doesn't match a trusted domain. Usually a `www.` vs bare-domain mismatch, a staging subdomain nobody added, or [`file://`/opaque origins](/docs/environments/electron/#bundled-apps).
3. Local development without the project's "Allow local origins" toggle or an explicit `localhost` domain. See [Localhost & development](/docs/environments/localhost/).
4. A Content-Security-Policy blocking the script or the API origin.
5. An adblocker blocking `cdn.analytics.canarycoders.es` or `api.analytics.canarycoders.es`.
6. The project's daily event cap has been reached.

Causes 1 to 3 are exactly what `/v1/validate` catches. Causes 4 to 6 never reach the server, so see [what /v1/validate can't see](#what-v1validate-cant-see).

## Walking through /v1/validate

Call it with the *exact* domain/origin pair a real visitor's browser would send. Pull them off the actual page (`location.hostname` for domain, `location.origin` for origin) rather than guessing:

```bash
curl "https://api.analytics.canarycoders.es/v1/validate?domain=myapp.com&origin=https://staging.myapp.com" \
  -H "Authorization: Bearer cly_live_5f3a9c2e1b7d4a80f6c3e9d2a1b4c7e0"
```

```json
{
  "domain": "myapp.com",
  "origin": "https://staging.myapp.com",
  "wouldAccept": true,
  "checks": {
    "domainOwnedByProject": true,
    "originPresent": true,
    "originParseable": true,
    "originIsLocal": false,
    "allowLocalOrigins": false,
    "originTrusted": true
  },
  "reasons": []
}
```

Each `checks` field mirrors one step of the real `/ingest` logic:

| Field | Fails when |
|---|---|
| `domainOwnedByProject` | `domain` doesn't match any of *this* project's trusted domains. Matching is on label boundaries, so `evil-myapp.com` never matches `myapp.com`. |
| `originPresent` | No `origin` was supplied to the check. This mirrors a request with no `Origin` header, which is always dropped. |
| `originParseable` | `origin` isn't a valid URL (it should look like `https://app.example.com`). |
| `originIsLocal` | Informational only. `true` when the origin's host is `localhost`, `127.0.0.1`, or `*.localhost`. |
| `originTrusted` | The origin's host doesn't pass the trust rule for its kind: local origins need `allowLocalOrigins` (or `localhost` explicitly trusted); everything else needs a label-boundary match against a trusted domain. |

`wouldAccept` is `domainOwnedByProject && originTrusted`. When it's `false`, `reasons` spells out which checks failed in plain English, so you don't have to reverse-engineer the table by hand.

<Aside type="note" title="Why this can't leak other projects' domains">
  `domainOwnedByProject: false` looks identical whether `domain` is malformed, unclaimed, or owned by someone else's project entirely. That is deliberate. The endpoint is key-authed and safe to expose precisely because it can never be used to probe whether a domain belongs to another project.
</Aside>

## What /v1/validate can't see

It is a pure server-side check of the domain and Origin acceptance rules. It cannot tell you whether the request ever reached the server in the first place.

### Content-Security-Policy

A CSP that doesn't allow the snippet or the API blocks the request in the browser before it is sent, so nothing shows up server-side to validate against. Add both origins:

```
Content-Security-Policy: script-src 'self' https://cdn.analytics.canarycoders.es; connect-src 'self' https://api.analytics.canarycoders.es;
```

### Adblockers

Ad and tracker blockers may block `lytics.js` itself or the `/ingest` request by URL pattern. This is also invisible to `/v1/validate`, since the check runs against a hypothetical request you provide rather than observing what actually left a real visitor's browser.

### The daily event cap

Once a project's daily event count exceeds its cap, further events are dropped silently: same `204`, no signal to the client. Check current usage against the cap on the dashboard's Settings page. This one isn't exposed via `/v1` or the SDK, only the dashboard UI.

## Still stuck?

Confirm `GET /v1/validate` with the exact domain/origin pair a real visitor's browser would send. A mismatch there is the only failure mode this endpoint is guaranteed to catch. Everything else on this page fails client-side, before the server ever sees a request.

## Related

<CardGrid>
  <LinkCard title="How it works" description="Why /ingest never returns an error, and the no-oracle design behind it." href="/docs/how-it-works/#silent-drops-why-ingest-never-returns-an-error" />
  <LinkCard title="Localhost & development" description="Getting events flowing from local dev servers." href="/docs/environments/localhost/" />
</CardGrid>

## Sitemap

- [Docs sitemap](https://analytics.canarycoders.es/docs/sitemap.md)
- [Full documentation as a single file](https://analytics.canarycoders.es/llms-full.txt)
