DocsAPI reference

API reference

Data API response contracts

5 min readReviewed July 2026

POST /api/visualize/data uses one response envelope for several result shapes. The envelope is stable at a conceptual level, while row fields depend on the visualization, aggregation, split, and SQL column mappings in the request.

Use this reference when you are building a typed client, a native chart, a customer-facing API, or a contract test around GraphJSON data.

Contract layers#

Treat a response as three layers:

Layer Examples Client policy
Transport HTTP status, JSON or text body Check before parsing a success shape
Envelope result, optional compare_result, optional metadata Validate explicitly
Query result timestamp, label, value, aggregation or split keys Validate against the request you sent

The Data API does not return one universal row schema. A client that sends a Samples request should not parse the response like a Single Line request, and a SQL table should not be forced into a chart-row type.

Base TypeScript types#

Start with deliberately broad transport types:

type JsonScalar = string | number | boolean | null;
type JsonValue =
  | JsonScalar
  | JsonValue[]
  | { [key: string]: JsonValue };

type DataEnvelope<Row extends Record<string, unknown>> = {
  result: Row[];
  compare_result?: Row[];
  metadata?: Record<string, unknown>;
  telemetry?: Record<string, unknown>;
};

type GraphJSONError = {
  error?: unknown;
};

Do not represent every response as any. Use a broad value at the HTTP boundary, validate it, and return a query-specific type to the rest of the application.

Validate the envelope#

function isObject(value: unknown): value is Record<string, unknown> {
  return typeof value === "object" && value !== null && !Array.isArray(value);
}

function parseEnvelope(
  value: unknown
): DataEnvelope<Record<string, unknown>> {
  if (!isObject(value) || !Array.isArray(value.result)) {
    throw new Error("Invalid GraphJSON data envelope");
  }

  if (
    value.compare_result !== undefined &&
    !Array.isArray(value.compare_result)
  ) {
    throw new Error("Invalid GraphJSON comparison result");
  }

  return value as DataEnvelope<Record<string, unknown>>;
}

This check proves only that the envelope exists. Validate each row against the request contract next.

Samples response#

A collection Samples request returns stored events:

{
  "result": [
    {
      "timestamp": 1785081600,
      "json": {
        "event": "checkout_completed",
        "account_id": "acct_42",
        "amount": 4900
      }
    }
  ]
}

Recommended type:

type SampleRow = {
  timestamp: number;
  json: Record<string, JsonValue>;
};

Validate:

  • timestamp is a finite Unix-second number
  • json is an object
  • required event fields match your application contract

GraphJSON flattens nested objects by default, so a stored property can be named context.page.path. With no_flatten: true, nested objects or arrays can remain inside json.

Samples are suitable for inspection and bounded exports. They are not a paginated whole-workspace archive.

Collection time-series response#

Single, cumulative, multi, stacked, ratio, and comparison lines return rows grouped by time:

{
  "result": [
    {
      "timestamp": "2026-07-24",
      "Count": 83
    },
    {
      "timestamp": "2026-07-25",
      "Count": 91
    }
  ],
  "metadata": {
    "granularity": "Day",
    "combined": 174
  }
}

The value key is query-derived. It can reflect:

  • Count
  • an aggregation and metric
  • a split value
  • a normalized SQL value-column alias

Do not select the first non-timestamp property and assume it is the intended series. Know the expected key from the request:

type TimeSeriesRow = {
  timestamp: string | number;
  [series: string]: string | number;
};

function numericSeries(
  row: Record<string, unknown>,
  seriesKey: string
) {
  const value = row[seriesKey];
  if (typeof value !== "number" || !Number.isFinite(value)) {
    throw new Error(`Expected numeric series ${seriesKey}`);
  }
  return value;
}

Collection results can format timestamps according to the selected granularity. SQL line mappings are normalized from Unix seconds and can be serialized as date strings. Parse the actual returned timestamp instead of assuming every result uses one representation.

Multi-series rows#

Multi and stacked lines place split values on each timestamp row:

{
  "result": [
    {
      "timestamp": "2026-07-25",
      "pro": 63,
      "starter": 28
    }
  ]
}

Split keys come from data. They are not a fixed enum unless your event contract makes them one.

When rendering:

  1. remove timestamp from the candidate series keys
  2. allowlist or cap known split values
  3. accept missing series in an individual time bucket
  4. reject non-numeric values
  5. group the long tail upstream when cardinality is unbounded

Do not create object properties or HTML directly from unsanitized split values without treating them as untrusted data.

Comparison responses#

A prior-period comparison or Compare Line can add compare_result:

{
  "result": [
    {"timestamp": "2026-07-25", "Count": 91}
  ],
  "compare_result": [
    {"timestamp": "2026-07-25", "Count": 80}
  ],
  "metadata": {
    "combined": 91,
    "compare_combined": 80,
    "granularity": "Day"
  }
}

compare_result is optional at the envelope level. It can also be present as an empty array for a SQL visualization or a query without comparable rows.

Render these states separately:

State Meaning
Property absent This response shape did not include a comparison
Empty array Comparison was supported but returned no rows
Rows present A comparison result is available

Do not turn an absent or empty comparison into a zero-percent change.

Table and categorical responses#

Collection Table, Bar, Pie, and Single Value responses can use query-derived keys:

{
  "result": [
    {"plan": "pro", "Count": 184},
    {"plan": "starter", "Count": 96}
  ],
  "metadata": {}
}

For collection visualizations, use the split and aggregation selected in the request as the expected columns.

SQL Bar, Pie, Funnel, and Single Value mappings are normalized:

{
  "result": [
    {"label": "pro", "value": 184},
    {"label": "starter", "value": 96}
  ],
  "compare_result": [],
  "metadata": {}
}

Recommended normalized type:

type LabelValueRow = {
  label: string | number | null;
  value: number;
};

An SQL Table is intentionally not normalized. It returns the selected columns as rows so callers can preserve a tabular result:

type SqlTableRow = Record<string, JsonValue>;

Ratio and comparison metadata#

Metadata helps presentation but is not an independent financial or operational ledger.

Common fields include:

type KnownMetadata = {
  title?: string;
  granularity?: string;
  combined?: number;
  compare_combined?: number;
};

Clients must tolerate additional metadata fields. Do not fail a valid response because a new additive metadata property appears.

telemetry can be present on some SQL-backed results. Treat it as optional diagnostic information, not a stable business-data contract.

Empty results#

A successful query can return:

{
  "result": [],
  "metadata": {}
}

That is different from:

  • an HTTP error
  • an invalid API key
  • a timed-out query
  • a response that cannot be parsed
  • a missing result property

Customer-facing applications need distinct loading, empty, unavailable, and invalid-response states.

Before showing “No activity,” confirm:

  1. the response was successful
  2. the envelope was valid
  3. the query range and time zone were intended
  4. the authorized tenant filter was present
  5. an empty result is a valid product state

Error responses#

Read the response as text first because error bodies can be JSON or plain text:

async function readGraphJSONResponse(response: Response) {
  const text = await response.text();
  let body: unknown = text;

  if (text) {
    try {
      body = JSON.parse(text);
    } catch {
      // Preserve the text response.
    }
  }

  if (!response.ok) {
    const detail =
      isObject(body) && body.error !== undefined
        ? JSON.stringify(body.error)
        : String(body);
    throw new Error(
      `GraphJSON ${response.status}: ${detail.slice(0, 300)}`
    );
  }

  return body;
}

Branch on HTTP status, not exact human-readable error wording.

Normalize at your boundary#

If your application uses several GraphJSON queries, return application-owned types:

type DailySignups = {
  day: string;
  signups: number;
};

async function loadDailySignups(): Promise<DailySignups[]> {
  const raw = await graphJSONPost(/* fixed request */);
  const envelope = parseEnvelope(raw);

  return envelope.result.map((row) => {
    if (
      typeof row.timestamp !== "string" ||
      typeof row.signups !== "number"
    ) {
      throw new Error("Invalid daily-signups row");
    }
    return { day: row.timestamp, signups: row.signups };
  });
}

This prevents query-derived GraphJSON fields from leaking across the entire application.

Contract tests#

Maintain one test per production query shape:

fixture contract
  → request builder
    → controlled GraphJSON test collection
      → response parser
        → application-owned result

Test:

  • one normal row
  • no rows
  • missing optional comparison
  • unexpected additive field
  • wrong value type
  • non-JSON error
  • unauthorized response
  • maximum useful row count

Use a non-production collection and a minimal fixture. Do not make CI depend on a high-volume live query.

Forward-compatibility rules#

Clients should:

  • require documented envelope fields
  • validate query-specific rows
  • tolerate additive envelope and metadata fields
  • tolerate new split values unless the application owns a closed enum
  • avoid depending on object-property order
  • avoid depending on exact error strings
  • pin and review generated OpenAPI types

Clients should not silently coerce an invalid numeric field to zero. A visible contract failure is safer than a plausible but incorrect customer metric.

Continue with the Data API, OpenAPI and client tools, and API compatibility policy.

Need a hand?

Tell us what you’re building and we’ll point you in the right direction.

Contact support