Veridion Disclosure API / 1.14.0

Quickstart

Node.js 20 or later. No key required. Free disclosure reads allow 25 rows per page and 60 requests per hour per IP.

Three requests

  1. GET /api/v1/disclosures with transaction_date_from set to 30 days before the run and limit=25. The unfiltered walk starts with the oldest positions, not the newest transactions.
  2. Repeat that exact URL with its returned ETag in If-None-Match. A 304 has no JSON body; a 200 contains the changed page.
  3. GET /api/v1/coverage. Read each block's population basis and measurement instant. Physical warehouse rows and served identities are different populations.
curl --fail --show-error --silent \
  https://www.veridionmarkets.com/examples/disclosure-quickstart.mjs \
  --output disclosure-quickstart.mjs
node disclosure-quickstart.mjs

The output contains the returned page, conditional status/body, and coverage response. An empty data array means this request returned no rows; it is not proof of no government filings. No example response or count is substituted on failure.

At this contract version, anonymous disclosure admission occurs before validation and conditional matching: a 304 or a subsequent 400 can consume a request. Do not assume revalidation is free. On 429 or 503, inspect Retry-After; the example stops instead of retrying automatically.

A 304 carries no quota headers. Measured 2026-09-18: the origin sets RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset and X-API-Version on every response, but the serving platform reduces a 304 to its validators (ETag, Cache-Control, Vary, Date) before it reaches you. This is not an origin choice and cannot be changed by the origin. A client that only ever revalidates will see its budget consumed but never reported. Read quota from the most recent 200, or issue an unconditional request when you need a fresh balance. The version you hold is safe: it is part of the entity tag, so a 304 can only match a response produced under the version currently served.

Executable source
import { resolve } from "node:path";
import { pathToFileURL } from "node:url";

export const API_ORIGIN = "https://www.veridionmarkets.com";

export function recentFilters(now = new Date()) {
  const from = new Date(now);
  from.setUTCDate(from.getUTCDate() - 30);
  return { transaction_date_from: from.toISOString().slice(0, 10), limit: "25" };
}

async function request(url, fetcher, headers = {}) {
  const response = await fetcher(url, {
    headers: { Accept: "application/json", ...headers },
    redirect: "error",
    signal: AbortSignal.timeout(30_000),
  });
  if (response.status === 304) {
    if (!headers["If-None-Match"]) throw new Error("Unexpected 304; no state committed");
    return { response, body: null };
  }
  const contentType = response.headers.get("content-type") || "";
  if (!/\bapplication\/(?:problem\+)?json\b/i.test(contentType)) {
    throw new Error(`HTTP ${response.status}: expected JSON; no state committed`);
  }
  const body = await response.json();
  if (!response.ok) {
    const error = new Error(`HTTP ${response.status}: ${body.code || "request_failed"}`);
    Object.assign(error, {
      code: body.code, requestId: response.headers.get("x-request-id"),
      retryAfter: response.headers.get("retry-after"),
    });
    throw error;
  }
  return { response, body };
}

// Three requests: a recent page, its conditional read, then coverage.
export async function preview({ fetcher = fetch, now = new Date() } = {}) {
  const url = new URL("/api/v1/disclosures", API_ORIGIN);
  url.search = new URLSearchParams(recentFilters(now)).toString();
  const first = await request(url, fetcher);
  if (!Array.isArray(first.body?.data)) throw new Error("Disclosure page unavailable");
  const etag = first.response.headers.get("etag");
  if (!etag) throw new Error("ETag unavailable; conditional request not attempted");
  const conditional = await request(url, fetcher, { "If-None-Match": etag });
  const coverage = await request(new URL("/api/v1/coverage", API_ORIGIN), fetcher);
  return {
    firstPage: first.body,
    conditional: { status: conditional.response.status, body: conditional.body },
    coverage: coverage.body,
  };
}

function requiredString(value) {
  return typeof value === "string" && value.length > 0;
}

function timestamp(value) {
  return typeof value === "string"
    && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,6})?(?:Z|[+-]\d{2}:\d{2})$/.test(value)
    && Number.isFinite(Date.parse(value));
}

// State is replaced only after every page succeeds. Keep one writer per replica.
/**
 * @typedef {{ disclosure_id: string, version_id: string, change: { type: string }, [field: string]: unknown }} Row
 * @typedef {{ transaction_date_from: string, limit: string }} Filters
 * @typedef {{ rows: Row[], filters: Filters, watermark: string }} State
 * @param {{ state?: State | null, filters?: Filters, fetcher?: typeof fetch, maxPages?: number }} options
 * @returns {Promise<State>}
 */
export async function walk({ state = null, filters = recentFilters(), fetcher = fetch, maxPages = 50 } = {}) {
  if (!Number.isSafeInteger(maxPages) || maxPages < 1) throw new Error("Invalid page budget");
  if (state && (!timestamp(state.watermark) || !Array.isArray(state.rows) || !state.filters)) {
    throw new Error("Invalid saved state; do not invent a watermark");
  }
  const fixedFilters = { ...(state ? state.filters : filters) };
  if (Object.keys(fixedFilters).some((key) => !["transaction_date_from", "limit"].includes(key))
    || !/^\d{4}-\d{2}-\d{2}$/.test(fixedFilters.transaction_date_from)
    || !/^([1-9]|1[0-9]|2[0-5])$/.test(String(fixedFilters.limit))) {
    throw new Error("Use the fixed recent-window filters and a free-tier limit from 1 to 25");
  }
  const rows = new Map();
  for (const row of state?.rows ?? []) {
    if (!requiredString(row?.disclosure_id) || !requiredString(row?.version_id) || rows.has(row.disclosure_id)) {
      throw new Error("Invalid saved rows; no state committed");
    }
    rows.set(row.disclosure_id, row);
  }
  let cursor = null;
  let pin = null;
  const seen = new Set();
  for (let pageNumber = 0; pageNumber < maxPages; pageNumber += 1) {
    const url = new URL("/api/v1/disclosures", API_ORIGIN);
    url.search = new URLSearchParams(fixedFilters).toString();
    if (state) url.searchParams.set("updated_since", state.watermark);
    if (cursor) url.searchParams.set("cursor", cursor);
    const { body } = await request(url, fetcher);
    const page = body?.page;
    if (!Array.isArray(body?.data) || !page
      || body.coverage?.rows_dropped_failed_validation !== 0
      || body.coverage?.rows_returned !== body.data.length
      || page.mode !== (state ? "changes" : "snapshot")
      || !Number.isSafeInteger(page.snapshot_generation) || page.snapshot_generation < 1
      || !timestamp(page.snapshot_as_of)
      || page.requested_updated_since !== (state?.watermark ?? null)
      || page.next_updated_since !== page.snapshot_as_of
      || typeof page.has_more !== "boolean") {
      throw new Error("Incomplete or invalid page; no state committed");
    }
    const pagePin = JSON.stringify([page.snapshot_generation, page.snapshot_as_of, page.next_updated_since]);
    if (pin !== null && pin !== pagePin) throw new Error("Snapshot changed; no state committed");
    pin = pagePin;
    for (const row of body.data) {
      if (!requiredString(row?.disclosure_id) || !requiredString(row?.version_id)
        || !["upsert", "supersede"].includes(row.change?.type)
        || (!state && row.change.type !== "upsert")) {
        throw new Error("Invalid change; no state committed");
      }
      if (row.change.type === "upsert") rows.set(row.disclosure_id, row);
      else if (rows.get(row.disclosure_id)?.version_id === row.version_id) rows.delete(row.disclosure_id);
    }
    if (!page.has_more) {
      if (page.next_cursor !== null) throw new Error("Unexpected cursor; no state committed");
      return { filters: fixedFilters, rows: [...rows.values()], watermark: page.next_updated_since };
    }
    if (!requiredString(page.next_cursor) || seen.has(page.next_cursor)) {
      throw new Error("Missing or repeated cursor; no state committed");
    }
    cursor = page.next_cursor;
    seen.add(cursor);
  }
  throw new Error("Page budget reached; no state committed. Narrow the initial window or use a licensed integration.");
}

if (process.argv[1] && pathToFileURL(resolve(process.argv[1])).href === import.meta.url) {
  try {
    console.log(JSON.stringify(await preview(), null, 2));
  } catch (error) {
    console.error(JSON.stringify({ error: error.message, code: error.code, request_id: error.requestId, retry_after: error.retryAfter }));
    process.exitCode = 1;
  }
}

Incremental walk

The first walk exhausts a pinned snapshot for the same recent window. The second uses its exact page.next_updated_since watermark and follows every cursor. Keep that window fixed between runs; changing it requires a new full pull. The page limit does not limit the total result.

node --input-type=module <<'JS'
import { walk } from './disclosure-quickstart.mjs';
let state = await walk();
state = await walk({ state });
console.log(JSON.stringify(state, null, 2));
JS

Store rows, filters, and watermark together only after walk returns. Reuse that state on the next poll. If any page fails, keep the previous state and watermark; do not commit a partial pull. The example stops after 50 pages and does not certify source completeness.

An upsert replaces the stored disclosure_id. A supersede removes it only when the stored version_id matches the event. A withdrawal of an older version cannot erase a newer one. Never derive a watermark from change.observed_at or group rows by ticker, amount or date.

For invalid_cursor or cursor_snapshot_unavailable, discard the unfinished walk and restart page one with the previous committed state. If the watermark predates retained history, rebuild the replica from a fresh full pull; do not silently advance the watermark past missed changes.

Client actions

21 problem types in the current catalogue. Branch on code, retain X-Request-ID, and follow the action below. A refusal is not an empty successful page.

unknown_query_parameterHTTP 400

Remove or correct the parameter. The detail field lists the parameters this operation accepts.

Fix the request; do not retry as sent.

method_not_allowedHTTP 405

Reissue the request as GET. The response carries an Allow header listing the methods this path accepts.

Fix the request; do not retry as sent.

invalid_requestHTTP 400

The detail field names each offending parameter and what it expected. Fix and resend.

Fix the request; do not retry as sent.

invalid_disclosure_idHTTP 400

Use the disclosure_id exactly as it appears on a served row.

Fix the request; do not retry as sent.

invalid_cursorHTTP 400

Restart from page one without a cursor and follow page.next_cursor or the Link: rel="next" header.

Restart from page one without a cursor.

temporal_history_unavailableHTTP 400

Use a timestamp at or after the floor, which is published on every response and in the detail field.

Fix the request; do not retry as sent.

unauthorizedHTTP 401

Check the key, or drop the Authorization header for free-tier access.

Fix the request; do not retry as sent.

insufficient_scopeHTTP 403

Use a key scoped for this endpoint, or request the scope through the access form on the API page.

Fix the request; do not retry as sent.

route_not_foundHTTP 404

Correct the path. The detail field lists every path this contract defines, and /api/v1/openapi.json is the machine-readable source.

Fix the request; do not retry as sent.

disclosure_not_foundHTTP 404

Check the identifier against a served row.

Fix the request; do not retry as sent.

no_snapshot_yetHTTP 404

Retry after the next daily snapshot; /v1/status reports the export state.

Retry later; the request was sound. Every 503 carries Retry-After with the minimum wait.

cursor_snapshot_unavailableHTTP 409

Restart the pull without a cursor; page one pins a new snapshot.

Restart from page one without a cursor.

rate_limitedHTTP 429

Wait for Retry-After, or request a key, which raises the limits and lifts the row cap.

Retry after the Retry-After header elapses.

api_rate_limit_unavailableHTTP 503

Wait for Retry-After, then retry. If it persists, quote X-Request-ID to support.

Retry later; the request was sound. Every 503 carries Retry-After with the minimum wait.

warehouse_unavailableHTTP 503

Retry later. If it persists, quote the X-Request-ID to support@veridionmarkets.com.

Retry later; the request was sound. Every 503 carries Retry-After with the minimum wait.

api_auth_unavailableHTTP 503

Retry later with the same key.

Retry later; the request was sound. Every 503 carries Retry-After with the minimum wait.

api_usage_meter_unavailableHTTP 503

Wait at least the Retry-After interval before retrying. The delay is a backoff instruction, not an estimate of recovery time.

Retry later; the request was sound. Every 503 carries Retry-After with the minimum wait.

contract_validation_failedHTTP 503

Quote the X-Request-ID to support@veridionmarkets.com; the row is identifiable from it.

Retry later; the request was sound. Every 503 carries Retry-After with the minimum wait.

temporal_capability_unavailableHTTP 503

Retry later, or issue the request without as_of for the current state.

Retry later; the request was sound. Every 503 carries Retry-After with the minimum wait.

signing_failedHTTP 503

Retry later.

Retry later; the request was sound. Every 503 carries Retry-After with the minimum wait.

manifest_timestamp_unreadableHTTP 503

Retry later. The snapshot file itself is unaffected; only its manifest is withheld.

Retry later; the request was sound. Every 503 carries Retry-After with the minimum wait.

Weekly Veridion brief

Rating changes, public disclosure activity, methodology notes, and product updates. One email per week. No advertising list resale.