Skip to main content
The checks below are ordered by likelihood. Start at the top unless the write key’s last-used value points to a later section.

First: is anything arriving at all?

Open Settings and check the write key’s last used time. It updates on every authenticated request:
  • “Never” (or stale) — requests aren’t reaching Glyph. Keep reading from the environment mismatch down through flushing.
  • Recent — requests arrive but the data isn’t where you’re looking, or messages are being rejected. Jump to wiring up onError and the sections after it.

You’re looking at the wrong environment

glyph_test_... keys send to the isolated test environment; glyph_pk_... keys send to live. The environments never mix. Make sure the workspace view matches the key used by the application.

The process exited before flushing

Messages queue locally and flush in batches (default: 20 messages or every 5 seconds). A process that exits with messages still queued loses them.
  • Serverless functions and API routes — the runtime can freeze or terminate the moment a response is sent. await glyph.flush() before responding, every time.
  • Scripts and CLIs — call close() (which flushes) before exiting. In Node, flushOnExit: true adds a best-effort safety net.
  • Python specifically — the client has no background thread. It only flushes when you enqueue a message (and a threshold has been reached) or when you call flush()/close(). A Python process that goes quiet holds its queue forever. Use the context manager (with Glyph(...) as glyph:) so close() always runs.
  • Browser — the SDK makes a best-effort flush when the tab is hidden. The queue is in memory, so a hard navigation, tab close, crash, blocked request, or failed unload can still lose messages. Capture critical events server-side. If you close() the client or disable autoFlush, you own flushing yourself.

You’re not seeing delivery errors

The SDKs report delivery problems through onError; they do not throw them from track, profile, group, or page. Configure the callback before debugging delivery:
The status tells you which of the next sections applies. Non-retryable failures drop the batch after calling onError — the callback is your only record of what was lost.

401 — the key is wrong, rotated, or revoked

A 401 is non-retryable, so the batch is dropped. Check whether the key was rotated while a deployment still has the old value. Update the environment variable and redeploy. Also check for whitespace or truncation; keys must begin with glyph_pk_ or glyph_test_.

422 — the payload failed validation

The SDKs validate before sending, so 422 mostly affects HTTP API callers: unknown fields (each message type accepts only its documented keys), an empty required string, or an unparseable timestamp. The response body’s issues array names each failing field. When using an SDK, the equivalent failure is a TypeError thrown at the call site — an empty userId, a missing email trait on profile, a value over 256 characters. Those never reach the queue at all.

403 — the test environment is full

Once the test environment contains 5,000 stored events, further requests return 403. A final accepted batch can place the stored total slightly over the threshold. Clear test data from Settings or switch to a live key. See Write keys.

429 — you’re being rate limited

Each write key has a budget (default 600 messages per minute, sliding window). The SDKs handle occasional 429s themselves — they honor Retry-After and keep the batch queued — so intermittent limiting delays data rather than losing it. Sustained limiting usually means an import is running unthrottled; pace it as shown in the backfill guide.

You’ve hit your plan’s customer profile limit

When the workspace reaches its profile limit, messages for new customers are skipped even though the request succeeds. Messages for existing customers are still accepted. Upgrade the plan or remove unnecessary profiles from the data you intend to backfill.

Events are dated January 1970

You passed epoch seconds where epoch milliseconds are expected. 1750000000 is June 2025 in seconds but January 21, 1970 in milliseconds — and it parses “successfully”. Multiply by 1,000, or pass an ISO 8601 string / native date object instead. Two related timestamp pitfalls:
  • Python naive datetimes are interpreted as the machine’s local time when serialized. Always construct backfill timestamps with tzinfo=timezone.utc (or the source’s real zone).
  • Backfilled events sort by their timestamp, not arrival time — if an import “isn’t showing”, check whether it landed further down the timeline than you scrolled.

Duplicate events

Every SDK call generates a fresh messageId, and the server dedupes on it — so SDK-level retries never double-count, but re-running a script re-sends everything as new messages. For imports you might run twice, use the HTTP API with deterministic message IDs, or track what you’ve already sent.

Browser only: an extension is blocking the request

Content and privacy extensions can block requests to in.glyphhq.io. If browser events are missing while server-side events arrive normally, check DevTools for ERR_BLOCKED_BY_CLIENT. There is no reliable client-side workaround; capture business-critical events server-side. If you see this error, report blocked browser events. Include your workspace, browser, extension, and the affected event names so we can investigate.

Still stuck?

Email hello@glyph.app — a founder answers. Include the SDK and version, a snippet of how you initialize it, and any status values your onError callback logged; those three usually pin it down in one reply.