DEV Community

Aleksander Sekowski
Aleksander Sekowski

Posted on

PostHog Capture Treats an Epoch Timestamp as Ingestion Time

You backfill six months of user signed up events into PostHog through the capture API. The request returns 200. Insights show a spike today, not a curve across 2025.

The events are not missing. They are stamped with the time PostHog received them.

{
  "api_key": "phc_abc123",
  "event": "user signed up",
  "distinct_id": "user-10024",
  "timestamp": 1770000000
}
Enter fullscreen mode Exit fullscreen mode

PostHog documents timestamp as ISO 8601. An epoch number is a different type. Capture still accepts the request. The historical value is not applied. The event lands at ingestion time.

"timestamp": "2026-07-26T06:00:00Z"
Enter fullscreen mode Exit fullscreen mode

I maintain Pixellint, an open source linter for pixels and analytics payloads. The PostHog pack (vendor/posthog) contracts single capture and batch against that capture API page. This is the finding on an epoch timestamp:

$ pixellint validate json @capture.json
rulepack: vendor/posthog (vendor: posthog)
  error   vendor.posthog.body.timestamp.invalid
          `timestamp` is `1770000000`, which does not match
          `^[0-9]{4}-[0-9]{2}-[0-9]{2}[T ][0-9]{2}:[0-9]{2}:[0-9]{2}`.
          PostHog documents the timestamp as ISO 8601. An epoch number is
          read as the ingestion time instead.
    fix:  Send an ISO 8601 timestamp, such as `2026-07-26T06:00:00Z`.
    docs: https://posthog.com/docs/api/capture
Enter fullscreen mode Exit fullscreen mode

Segment's HTTP API uses the same ISO 8601 rule. Amplitude wants the opposite: milliseconds since epoch, 13 digits, and a 10-digit seconds value lands in 1970. If your pipeline already emits epoch for Amplitude, do not reuse the field on PostHog.

Every event needs a distinct_id

distinct_id is required, on a single capture and on every element of batch. PostHog's capture docs are explicit about the failure mode: a missing event name, a missing distinct_id, or an empty distinct_id is not ingested, and the endpoint still returns 200 OK. The status code is not the contract.

{
  "api_key": "phc_abc123",
  "event": "user signed up",
  "properties": { "plan": "pro" },
  "timestamp": "2026-07-26T06:00:00Z"
}
Enter fullscreen mode Exit fullscreen mode
  error   vendor.posthog.body.distinct_id.missing
          `distinct_id` is required on PostHog capture requests but is not
          present. It identifies the person the event belongs to.
    fix:  Send the same `distinct_id` you use elsewhere for this person.
Enter fullscreen mode Exit fullscreen mode

event is required the same way. A nameless capture is not an event.

api_key is required on the envelope. The project key from project settings, not a personal API key. Capture rejects the request without it; the pack fails the payload before you find that out from a 401.

Batch is the same contract, per row

A batch body is an array of the same event object. The pack walks every row. One missing distinct_id in a 500-event backfill is one error, not a silent hole in the middle of the file.

{
  "api_key": "phc_abc123",
  "batch": [
    { "event": "user signed up", "distinct_id": "user-1", "timestamp": "2026-01-01T00:00:00Z" },
    { "event": "user signed up", "timestamp": "2026-01-02T00:00:00Z" }
  ]
}
Enter fullscreen mode Exit fullscreen mode

The second row is the one that fails. The first is fine. That is the point of checking the body before the POST.

A real historical backfill also needs historical_migration: true on the batch so PostHog does not treat the volume as a spike. That is an ingest flag, not a shape check, and the pack does not invent a rule for it.

What the CLI actually prints

The pack matches posthog.com capture paths (/e/, /capture/, /batch/, /i/v0/e) on a URL, and it matches a JSON body that carries distinct_id or event (and does not look like Segment or Amplitude). You can lint the body in a fixture with no host at all.

pixellint validate json @capture.json
Enter fullscreen mode Exit fullscreen mode
rulepack: core
  ok
rulepack: vendor/posthog (vendor: posthog)
  error   vendor.posthog.body.timestamp.invalid  ...

1 error(s), 0 warning(s), 0 info message(s) across 2 rulepack(s).
Enter fullscreen mode Exit fullscreen mode

Exit code 1. Same catalog from Node:

import { validate, isOk } from "pixellint";

const summary = validate(JSON.stringify(payload), { kind: "json" });
isOk(summary); // false
Enter fullscreen mode Exit fullscreen mode
cargo install pixellint
# or: npm install pixellint
Enter fullscreen mode Exit fullscreen mode

The contracts, with citations, live on the PostHog capture pack page. Paste a body into the browser playground if you do not want a clone. Nothing you paste leaves the browser.

The short version

timestamp is ISO 8601, not an epoch number. An epoch value does not backfill; it stamps now. Every event needs event, distinct_id, and the project api_key. Batch rows are checked one at a time.

Pixellint is independent of PostHog. The rule ids above cite the capture API docs because that is where the requirements live, not because this is an official tool.

Top comments (0)