The public report API

The operations under /v1/public that public links and embeds call with a link's token or a signed token, what they answer, and the problems they can return.

These operations open one report's published version, with no session and no cookies. They're what Halo's report pages call for public links and embeds, so most people never need them directly. Use them to read a report's document, to find an element's id, or to diagnose an embed.

Calling it

Send a token with every call:

GET /v1/public/report HTTP/1.1
Host: api.usehalo.net
Authorization: Bearer hlp_…
TokenLooks likeFrom
Public linkhlp_…The report's Share dialog. See Public links and snapshots
Embed linkhle_…The report's Share dialog. See Embed a report on your site
Signed embed tokenA JWTYour server. See Signed embeds

A token opens one report. Any origin may call these operations, without credentials. Halo's PDF renderer has a fourth kind of its own, hlr_…, which nothing issues to a browser, and it shows up only there, as a link.kind of render.

Rate limits

Calls are limited per token and per client IP, with a default of 6,000 a minute per token and 600 a minute per IP. Past either, the answer is 429 rate_limited.

Problems

Errors are RFC 9457 problem responses, and code is the stable part to read:

{
  "type": "https://usehalo.net/problems/link_not_found",
  "title": "Not Found",
  "status": 404,
  "code": "link_not_found",
  "detail": "This link doesn't work. It may have been turned off, or the report removed.",
  "requestId": "…"
}
CodeStatusMeans
link_not_found404Unknown, or turned off, or its report is in the Trash
link_expired410Past its expiry. The problem also carries expiredAt, and organization: { name, theme, logoUrl, showBadge }, so a page can wear the brand, with "Powered by Halo" only where the report shows it
report_not_published409It has never been published
invalid_token401No token, or a signed token that doesn't verify, has expired, or names another organization's report. detail says why
shared_field403A query beyond what the report shows
locked_filter_gone409A locked filter on the published report no longer fits its datasets, so Halo won't answer rather than show every row. Publish the report again
not_found404A region set the report's maps don't draw
snapshot_not_found404This answer isn't in the snapshot. Run it as a live query
no_thumbnail404This edition has no picture of its first page
edition_not_found404The report has no edition by that name
edition_withdrawn410Someone withdrew this edition. detail holds their note, if they left one
edition_figures_only403This edition shows only its published figures, so it takes no queries
invalid_query422A query that doesn't plan against the dataset
source_error502The source failed. Try again later
query_busy429Your organization is running as many queries as it can
query_timeout504The source took too long
rate_limited429Too many requests for this token or IP, or past ten downloads of a kind in an hour
downloads_off403The report's author hasn't allowed this download, or a signed embed asked for a PDF
export_too_large422More than 10,000 rows to export. Filter the report to fewer
pdf_unavailable503PDFs aren't available right now

The element's own embed-only codes are in The <halo-report> element.

GET /v1/public/report

The report a token opens: its published version, with what it takes to draw it.

{
  report: { id: string; title: string; description: string; publishedAt: string; version: number };
  document: Report;                       // the published version, upgraded to the current spec
  charts: Record<string, Visualization>;  // saved charts it places, by chartId: as they are now, or an edition's as published
  datasets: Record<string, {              // the datasets it queries, by id
    fields: { name: string; label: string; type: FieldType; role: "dimension" | "measure" }[];
    defaultDateField?: string;
  }>;
  organization: {
    name: string;
    slug: string;
    theme: Theme | null;                  // null means Halo's
    logoUrl?: string;
    showBadge: boolean;                   // show "Powered by Halo"
  };
  env: {
    timeZone: string;
    weekStartsOn: number;                 // 0 Sunday, 1 Monday
    fiscalStartMonth: number;
    anchor?: string;                      // the date it reads as of, when it isn't today
    period?: { start: string; end: string };  // the whole period an anchored report covers
  };
  sharing: { allowExport: boolean; allowPdf: boolean };
  edition?: {                             // the edition this is: its document, snapshot and version are the edition's
    slug: string;                         // such as 2026-03
    label: string;                        // what it's called, such as March 2026
    periodStart: string;
    periodEnd: string;
    anchor: string;                       // the date it reads as of
    publishedAt: string;
    correctedAt?: string;
    correctionNote?: string;
    liveFilters: boolean;                 // false: readers see only the published figures
  };
  editions?: { latest: string; latestLabel: string; count: number };  // on a report with editions
  snapshot?: {                            // public and embed links of a report on snapshots, once built
    id: string;
    builtAt: string;
    answers: string[];                    // the questions it answers, by key
    regions: string[];                    // the custom region sets it holds, by id
  };
  link: {
    kind: "public" | "embed" | "signed" | "render";
    expiresAt?: string;
    element?: string;                     // signed embeds of one element
    lockedFilters: Filter[];              // a signed embed's locked filters
  };
}

datasets[…].fields holds only the fields the report's queries may name. To find an element's id, read document.pages[].elements[].id.

On a report with editions, send ?edition= with an edition's slug, or latest, the default.

Problems: link_not_found, link_expired, report_not_published, edition_not_found, edition_withdrawn, locked_filter_gone, invalid_token, rate_limited.

Each read counts as a view, and is recorded in the audit log as report.viewed, with via as public, embed or signed. Views are written in batches.

POST /v1/public/report/query

Runs a query the published report could make itself.

{
  "datasetId": "…",
  "request": {
    "specVersion": 1,
    "datasetId": "…",
    "dimensions": [{ "field": "region" }],
    "measures": [{ "field": "revenue" }],
    "filters": [],
    "sorts": [],
    "limit": 100
  }
}

The top-level datasetId is the one used, and the request's own is ignored. The answer has columns, rows (an array per row, in column order), stats and warnings, plus totals when the request asks for them. stats includes rowCount, hasMore, timeZone, cached and computedAt.

Scope. Halo checks this before planning, and answers 403 shared_field otherwise:

  • The dataset is one the published document or its saved charts use.

  • Every field the request names is one the document lets a reader reach: fields its elements use, its controls filter, its charts' swaps offer and drill along.

  • Every parameter it names is one of the report's.

Scope covers datasets and fields, not values. The report's locked filters are added here to every query over a dataset they reach, and can't be removed. Its filters that aren't locked are applied in the reader's browser and aren't added here, so a request can leave them out and read other values of a field in scope, as aggregates. The limits Halo adds are in the table below.

Who's asking.

TokenLocked filtersRow policies see
Public or embed linkThe report'sNobody, so a dataset with row policies shows no rows
Signed embedThe report's and the token's, each added on every dataset it reaches, and none can be removed. A row has to pass bothThe token's attributes

The result cache applies as usual.

Problems: the token problems above, and shared_field, locked_filter_gone, invalid_query, source_error, query_busy, query_timeout, rate_limited.

POST /v1/public/report/export

An element's rows as a file, where the author turned on People who open public links and embeds can export data (sharing.allowExport). The body names a query as POST /v1/public/report/query takes it, and what to write:

{
  datasetId: string;
  request: QueryRequest;          // its limit caps the rows: send 1000000 for every row
  format: "csv" | "xlsx" | "json";
  columns?: string[];             // column keys, in order
  name?: string;                  // the file's name, without the extension
  snapshot?: { id: string; key: string }; // the snapshot answer the page drew it from
}

The answer is the file, with Content-Disposition naming it and X-Halo-Rows counting its rows. Up to 10,000 rows. With snapshot, the rows come from that answer when the token reads the snapshot; otherwise the query runs live, with the link's row policies and locked filters. Ten an hour for one client IP and link.

Problems: downloads_off, export_too_large, shared_field, rate_limited, the query's problems, and the token problems.

POST /v1/public/report/pdf and GET /v1/public/report/pdf/{id}

The report as a PDF, printed as the link shows it, where the author turned on They can download the report as a PDF (sharing.allowPdf). Public and embed links only: signed embeds get downloads_off.

{ id?: string; status: "queued" | "running" | "succeeded" | "failed" | "canceled"; error?: string; url?: string; filename?: string }

POST answers 202. When the file is printed already, for the same snapshot, edition or hour, it has url, which downloads it for the next hour. Otherwise it has id: ask GET /v1/public/report/pdf/{id} every second or two until status is succeeded, then follow url. Ten POSTs an hour for one client IP and link.

Problems: downloads_off, pdf_unavailable, not_found for another report's job, rate_limited, and the token problems.

POST /v1/public/report/goals

The goals a goal scorecard on the report shows, and how each stands, read as the token's reader. The body names them as the scorecard does: { "goalIds": ["…"] } in order, or { "datasetId": "…" } for every goal on a dataset. The answer is { items: [...] }, one card per goal with name, goal, actual, difference, percentOfGoal and status. A goal that's deleted, or that the reader can't open, comes back with missing: true.

Problems: shared_field for goals no scorecard on the report shows, edition_figures_only, and the token problems.

GET /v1/public/report/images/{id}

The addresses of an image the published report places, with its alt text and sizes. ?edition= names the edition.

Problems: not_found for an image the report doesn't place, and the token problems.

GET /v1/public/report/regions/{id}

A custom region set the report's maps draw.

{ id: string; name: string; version: number; kind: "shapes"; topology: TopoJSON }
{ id: string; name: string; version: number; kind: "zip_codes"; regions: { name: string; zips: string[] }[] }

?edition= names the edition whose document draws the map.

Problems: not_found for a set the report's maps don't draw, locked_filter_gone, and the token problems.

GET /v1/public/report/snapshots/{id}/answers/{key}

An answer from the report's snapshot, the same shape as a query's. {id} is snapshot.id from GET /v1/public/report. {key} is one of snapshot.answers: the SHA-256, in hex, of the canonical JSON of [datasetId, request], meaning what JSON.stringify writes with every object's keys sorted, for the request as you'd send it.

Answers carry Cache-Control: private, max-age=86400, immutable, since a snapshot's objects never change. An edition's carry private, max-age=60 instead, since an edition can be withdrawn.

Problems: snapshot_not_found, for a key the snapshot doesn't hold, a signed token, another report's snapshot, or a report no longer on snapshots. Ask that question with POST /v1/public/report/query instead. Also the token problems.

GET /v1/public/report/snapshots/{id}/regions/{key}

A region set from the snapshot, as GET /v1/public/report/regions/{id} answers it, by its id. {key} is one of snapshot.regions.

Problems: as for answers.

GET /v1/public/report/editions

What a link can read of the report's editions, newest period first. Withdrawn editions are left out.

{ items: { slug: string; label: string; periodStart: string; periodEnd: string; publishedAt: string; correctedAt?: string; thumbnailUrl?: string }[] }

Problems: the token problems.

GET /v1/public/report/snapshots/{id}/thumbnail

An edition's first-page picture, at the thumbnailUrl the list above gives, read with the same token.

Problems: no_thumbnail, for an edition with no picture, and the token problems.

GET /v1/public/report/preview

What a link shows when it's pasted into another site: title, description, siteName, and hasImage, which says whether there's a picture. Halo's edge gives these words to crawlers. It records no view, and a revoked or expired link answers as GET /v1/public/report does.

GET /v1/public/report/preview/image

That picture, 1200 by 630, taken from the report's snapshot when it was published. Only a report on snapshots has one.

GET /v1/public/report/frame

Who may frame the report: the sources for the CSP frame-ancestors header.

{ "frameAncestors": ["https://example.com", "https://*.example.com"] }
TokenAnswer
Public link["'none'"]. Public links are never framed
Embed link or signed embedThe report's allowed sites as https:// sources, with http://localhost:* or http://localhost:{port} for local ones. Your organization's own Halo address, https://{org}.usehalo.net, and the live domains of its Halo CMS websites are always included

Problems: the token problems.