> ## Documentation Index
> Fetch the complete documentation index at: https://docs.hadoseo.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Export Page Snapshots

> Export which rendered HTML version each AI crawler was served for a page, day by day

```
POST /functions/v1/export-page-snapshots
```

<Info>
  **Beta.** This endpoint is new and its response shape may still change. It is
  the drill-down companion to
  [Export Page Analytics](/api-reference/endpoint/export-page-analytics): that
  endpoint tells you *how much* bots crawled each page, this one tells you
  *exactly what HTML they got*.
</Info>

Returns, for **one page of one domain**, which rendered HTML snapshot each bot was served on each day — plus a download link for the HTML itself. This makes **propagation lag** visible: on the same day ClaudeBot may already hold your new content while GPTBot is still being served the previous version.

Every response row is **one date**, with the bots nested inside.

<Warning>
  Always call this endpoint from a **server-side environment** (backend API, serverless function, build script, etc.). Never include your API key in client-side code — it will be visible to anyone inspecting your frontend.
</Warning>

<Info>
  Programmatic (API key) access requires the **Pro plan and above** — requests
  from Starter accounts return `403 plan_upgrade_required`.
</Info>

<Note>
  Snapshot data begins on **2026-07-09** (`legacyCutover`). Windows that start
  earlier are automatically clamped to that date (the response echoes your
  original start as `requestedStartDate`); windows that end before it return
  `400 invalid_request`.
</Note>

## One page per request

Unlike the other export endpoints, this one requires a `url` — there is no site-wide mode. Naming a single page keeps the query an index lookup instead of an aggregate over every path you own, which is what keeps it fast.

Use [Export Page Analytics](/api-reference/endpoint/export-page-analytics) to discover *which* pages to ask about: the `path` values it returns can be passed straight back here as `url`.

## Request

### Headers

| Header          | Required | Description                    |
| --------------- | -------- | ------------------------------ |
| `Authorization` | Yes      | `Bearer hado_sk_your_key_here` |
| `Content-Type`  | Yes      | `application/json`             |

### Body Parameters

| Parameter   | Type                | Required | Description                                                                                                                                                                                                   |
| ----------- | ------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `domainId`  | string              | **Yes**  | The domain to export. Get IDs from [List Domains](/api-reference/endpoint/list-domains).                                                                                                                      |
| `url`       | string              | **Yes**  | The page to export. Accepts a full URL (`https://example.com/blog/post`) or a bare path (`/blog/post`). Query strings, fragments and trailing slashes are stripped — the resolved value comes back as `path`. |
| `period`    | string              | No       | Rolling window ending today: `1d`, `7d`, `30d`, `90d`, or `365d`. Defaults to `30d`. **Cannot** be combined with `startDate`/`endDate`.                                                                       |
| `startDate` | string              | No       | Start of an explicit date range, `YYYY-MM-DD` (UTC, inclusive).                                                                                                                                               |
| `endDate`   | string              | No       | End of an explicit date range, `YYYY-MM-DD` (UTC, inclusive). Optional — defaults to **today** when `startDate` is supplied. Cannot be sent without `startDate`.                                              |
| `pageSize`  | number              | No       | **Days** per response, `1`–`400`. Defaults to `100`. Because each row is a date, pages are measured in days — a date is never split across two pages.                                                         |
| `botId`     | string \| string\[] | No       | Only include these bots (e.g. `"gptbot"` or `["gptbot", "claudebot"]`).                                                                                                                                       |
| `tier`      | string \| string\[] | No       | Only count crawls in these tiers: `verified`, `declared`, `unknown`.                                                                                                                                          |
| `cursor`    | string              | No       | Continuation token from a previous response's `nextCursor`. When set, **all other parameters are taken from the cursor** — it pins the page, window, filters and page size.                                   |

Window rules (same as the other export endpoints): `period` and `startDate`/`endDate` are mutually exclusive, dates must be real `YYYY-MM-DD` calendar dates, neither may be in the future, `startDate ≤ endDate`, and the span is capped at **365 days**.

### Example Request

```bash theme={null}
curl -X POST https://api.hadoseo.com/functions/v1/export-page-snapshots \
  -H "Authorization: Bearer hado_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "domainId": "11111111-1111-1111-1111-111111111111",
    "url": "https://example.com/pricing",
    "period": "7d"
  }'
```

## Response

### Success (200)

```json theme={null}
{
  "generatedAt": "2026-08-14T12:00:00.000Z",
  "period": "7d",
  "startDate": "2026-08-08",
  "endDate": "2026-08-14",
  "userId": "00000000-0000-0000-0000-000000000000",
  "legacyCutover": "2026-07-09",
  "domain": "example.com",
  "domainId": "11111111-1111-1111-1111-111111111111",
  "role": "owner",
  "url": "https://example.com/pricing",
  "path": "/pricing",
  "rows": [
    {
      "day": "2026-08-13",
      "total": 12,
      "tiers": { "verified": 12, "declared": 0, "unknown": 0 },
      "bots": {
        "claudebot": {
          "total": 3,
          "tiers": { "verified": 3, "declared": 0, "unknown": 0 },
          "served": [
            {
              "servedFrom": "cache",
              "snapshotId": "92c63e55-bfc6-4e0b-93f2-f1aff7a2f95a",
              "tiers": { "verified": 3, "declared": 0, "unknown": 0 },
              "total": 3,
              "lastSeenAt": "2026-08-13T12:04:11.000Z"
            }
          ]
        },
        "gptbot": {
          "total": 9,
          "tiers": { "verified": 9, "declared": 0, "unknown": 0 },
          "served": [
            {
              "servedFrom": "render",
              "snapshotId": "92c63e55-bfc6-4e0b-93f2-f1aff7a2f95a",
              "tiers": { "verified": 5, "declared": 0, "unknown": 0 },
              "total": 5,
              "lastSeenAt": "2026-08-13T10:02:55.000Z"
            },
            {
              "servedFrom": "cache",
              "snapshotId": "92c63e55-bfc6-4e0b-93f2-f1aff7a2f95a",
              "tiers": { "verified": 4, "declared": 0, "unknown": 0 },
              "total": 4,
              "lastSeenAt": "2026-08-13T14:31:02.000Z"
            }
          ]
        }
      }
    }
  ],
  "snapshots": {
    "92c63e55-bfc6-4e0b-93f2-f1aff7a2f95a": {
      "contentHash": "a9f5d1c0b7e28843...",
      "renderStatus": 200,
      "contentSizeBytes": 51904,
      "htmlUrl": "https://....r2.cloudflarestorage.com/...?X-Amz-Signature=..."
    }
  },
  "htmlUrlExpiresAt": "2026-08-15T12:00:00.000Z",
  "dayCount": 1,
  "nextCursor": null
}
```

### Fields

| Field                               | Type           | Description                                                                                                                      |
| ----------------------------------- | -------------- | -------------------------------------------------------------------------------------------------------------------------------- |
| `generatedAt`                       | string         | ISO 8601 timestamp of when the export was produced.                                                                              |
| `period`                            | string \| null | The rolling period applied, or `null` for an explicit range **or any cursor request**.                                           |
| `startDate` / `endDate`             | string         | Resolved window (`YYYY-MM-DD`, inclusive). `startDate` may be later than requested if the window was clamped to `legacyCutover`. |
| `requestedStartDate`                | string         | Present only when the window was clamped — your original start date.                                                             |
| `userId`                            | string         | The account that owns the API key.                                                                                               |
| `legacyCutover`                     | string         | First date with snapshot data (`2026-07-09`).                                                                                    |
| `domain` / `domainId` / `role`      | string         | The exported domain and your access level (`owner`, `manager`, `viewer`).                                                        |
| `url`                               | string         | The `url` exactly as you sent it.                                                                                                |
| `path`                              | string         | What that URL normalized to — the key crawls are recorded against.                                                               |
| `rows[].day`                        | string         | The date (`YYYY-MM-DD`, UTC). **One row per date**, ascending.                                                                   |
| `rows[].total`                      | number         | All crawls of this page on this day (after filters).                                                                             |
| `rows[].tiers`                      | object         | The day's `verified` / `declared` / `unknown` split.                                                                             |
| `rows[].bots`                       | object         | Keyed by bot ID. Each bot has its own `total`, `tiers`, and a `served` list.                                                     |
| `rows[].bots[].served[]`            | array          | One entry per distinct `servedFrom` × snapshot combination for that bot that day — see below.                                    |
| `rows[].bots[].served[].servedFrom` | string         | How the response was produced: `cache`, `render`, or `origin`.                                                                   |
| `rows[].bots[].served[].snapshotId` | string         | Key into the `snapshots` dictionary — the HTML version this bot received.                                                        |
| `snapshots`                         | object         | Snapshot metadata, deduplicated by ID (a version repeats across many day × bot combinations).                                    |
| `snapshots[].contentHash`           | string         | SHA-256 of the served HTML. Identical hash means byte-identical content.                                                         |
| `snapshots[].renderStatus`          | number \| null | HTTP status of the render that produced this snapshot.                                                                           |
| `snapshots[].contentSizeBytes`      | number \| null | Size of the served HTML.                                                                                                         |
| `snapshots[].htmlUrl`               | string \| null | Temporary direct download link for the HTML. `null` when the stored copy is unavailable — see below.                             |
| `htmlUrlExpiresAt`                  | string         | When every `htmlUrl` in this response stops working.                                                                             |
| `dayCount`                          | number         | Number of dates on this page.                                                                                                    |
| `nextCursor`                        | string \| null | Pass as `cursor` to fetch the next page. `null` on the last page.                                                                |

<Note>
  **Why a bot can have several `served` entries in one day.** A bot may be
  served from more than one place on the same day — some hits from cache, others
  from a fresh render — or it may see more than one version if you republished.
  Each combination is its own entry so nothing is lost; the bot's `total` and
  `tiers` are the sum of them.
</Note>

### Downloading the HTML

`htmlUrl` is a temporary, pre-authorized link: fetch it with a plain `GET`, no `Authorization` header. It is served as **plain text**, so opening it in a browser shows the raw source rather than rendering the captured page.

```bash theme={null}
# The HTML is stored gzipped — use --compressed, or you'll get binary output.
curl --compressed "$HTML_URL" -o snapshot.html
```

<Warning>
  **Treat `htmlUrl` as a secret.** Anyone with the link can read that page's
  stored HTML until `htmlUrlExpiresAt` — roughly 24 hours — without
  authenticating. Don't log these links or paste them anywhere public. For a
  durable reference that re-checks permissions on every request, keep the
  `snapshotId` and use [Get Snapshot](/api-reference/endpoint/get-snapshot)
  instead.
</Warning>

<Note>
  **`htmlUrl` can be `null`.** Snapshot HTML is retained on a rolling basis
  while the crawl history is kept indefinitely, so older entries may have
  metadata (`contentHash`, `renderStatus`, `contentSizeBytes`) with no
  downloadable copy left. The day-by-day record of which bot saw which version
  stays accurate either way.
</Note>

### Error Responses

| Status | Body                                                                     | Description                                                                                                                                                       |
| ------ | ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 400    | `{ "error": "invalid_request", "message": "..." }`                       | Missing `domainId` or `url`; an invalid window, `pageSize`, `botId`, or `tier`; or a window that ends before `legacyCutover`. The `message` field explains which. |
| 400    | `{ "error": "invalid_cursor", "message": "..." }`                        | The `cursor` token is malformed or expired. Restart from the first page.                                                                                          |
| 401    | `{ "error": "invalid_api_key" }`                                         | API key is missing, invalid, or revoked.                                                                                                                          |
| 403    | `{ "error": "plan_upgrade_required" }`                                   | Programmatic export requires the Pro plan or above.                                                                                                               |
| 404    | `{ "error": "domain_not_found" }`                                        | The `domainId` doesn't exist or isn't accessible to your account.                                                                                                 |
| 429    | `{ "error": "rate_limit_exceeded_monthly", "usage": 300, "limit": 300 }` | Rate limit exceeded. The code is `rate_limit_exceeded_monthly` or `rate_limit_exceeded_per_minute` — match on the `rate_limit_exceeded` prefix to catch both.     |

<Tip>
  A URL that resolves fine but has never been crawled returns `200` with an
  empty `rows` array and `path` populated — **not** a `404`. That lets you tell
  "this page was never crawled" apart from "I sent a bad URL".
</Tip>

## Examples

### Spotting propagation lag

Find dates where bots were not all on the same version of a page:

```javascript theme={null}
async function findVersionSkew(domainId, url) {
  const res = await fetch("https://api.hadoseo.com/functions/v1/export-page-snapshots", {
    method: "POST",
    headers: {
      "Authorization": `Bearer ${process.env.HADOSEO_API_KEY}`,
      "Content-Type": "application/json",
    },
    body: JSON.stringify({ domainId, url, period: "30d" }),
  });
  if (!res.ok) throw new Error(`${res.status}: ${(await res.json()).message}`);
  const data = await res.json();

  return data.rows.flatMap((row) => {
    // Every distinct version served on this day, by bot.
    const byBot = Object.entries(row.bots).map(([botId, bot]) => ({
      botId,
      hashes: [...new Set(bot.served.map((s) => data.snapshots[s.snapshotId].contentHash))],
    }));
    const allHashes = new Set(byBot.flatMap((b) => b.hashes));
    return allHashes.size > 1 ? [{ day: row.day, byBot }] : [];
  });
}
```

### Auditing what a specific bot was served

```bash theme={null}
curl -X POST https://api.hadoseo.com/functions/v1/export-page-snapshots \
  -H "Authorization: Bearer hado_sk_your_key_here" \
  -H "Content-Type: application/json" \
  -d '{
    "domainId": "11111111-1111-1111-1111-111111111111",
    "url": "/pricing",
    "period": "30d",
    "botId": "gptbot",
    "tier": "verified"
  }'
```

<Tip>
  Each API request counts against your monthly quota. `pageSize` counts days, so
  a single request can cover a whole 365-day window (`pageSize: 400`) rather
  than paging through it.
</Tip>
