---
title: "Dry-run an archive against every import screen"
method: POST
path: "/api/pages/ingest/validate"
tags: ["pages"]
---

# Dry-run an archive against every import screen

`POST /api/pages/ingest/validate`

## Request

- Base URL: `https://app.goosybear.ai`
- URL: `https://app.goosybear.ai/api/pages/ingest/validate`
- Auth: HTTP bearer

Runs the SAME screens `ingestPageArchive` runs — the manifest schema, the archive and path guards, the dependency allowlist, recipe resolution, the conformance gate and every declared ceiling — over the archive you send, and answers the FULL list of refusals at once. It spends no build, mints no revision and writes nothing: no site is created or modified, and sending the same archive to the import afterwards is what actually imports it. Read `screensRun` before treating an empty `refusals` as a guarantee: anything we cannot decide without compiling your project stays a build result. Authenticated exactly like the import, and metered as a read.

## Query parameters

- `workspace` string

## Response `200`

The archive was screened. `data.ok` is the verdict; a `200` with `data.ok: false` is the ordinary answer for an archive that would be refused.

- object
  - `ok` true, required
  - `data` object, required
    - `ok` boolean, required: `true` when nothing refused — importing these exact bytes would be accepted. `false` means `refusals` is non-empty.
    - `kind` 'bundle' | 'prebuilt' | 'source:astro' | 'source:next' | 'source:vite' | 'source:static-generator' | 'not_a_site', required: What the archive was classified as, from its CONTENTS.
    - `markers` string[], required: The classifier's evidence, strongest first.
    - `recipe` string, nullable, required: The build recipe your project would be conformed onto, when one resolved.
    - `deploySource` 'declared' | 'legacy' | 'synthesized', nullable, required: Where the deploy manifest came from — your `pages.deploy.json`, a translated legacy `pages.functions.json`, or one we synthesized.
    - `siteImportEnabled` boolean, required: Whether source-project importing is switched on for your account. It CHANGES which screens apply, so two answers for the same bytes are only comparable when this matches.
    - `uploadBytes` integer, required: The compressed bytes you sent.
    - `screensRun` string[], required: Which screens actually ran. A screen that did not apply to this archive is ABSENT — so an empty `refusals` is 'these screens passed', never 'everything passed'. Anything we cannot decide without compiling your project (a type error, a failing integration) is not here and stays a build result.
    - `refusals` object[], required: EVERY refusal, not the first one. The list is in the order the rail screens run, so fixing them top-down is fixing them in the order they would otherwise have cost you builds.
      - `screen` string, required: Which screen said no — a member of `screensRun`.
      - `where` 'upload' | 'read' | 'build' | 'publish', required: The stage a real import would have discovered it at.
      - `code` string, required: The same error code the import itself answers with.
      - `reason` string, nullable, required: The specific guard, when the screen named one.
      - `message` string, required: The refusal sentence, exactly as an import states it.
      - `path` string, nullable, required: The offending file, when the screen named one.
    - `advisories` object[], required: Figures your archive is past that an import REPORTS but does not refuse. They never make `refusals` non-empty.
      - `screen` string, required: Which screen reported it — a member of `screensRun`.
      - `code` string, required: Stable code — `database.migration-count`. Key off this, never the sentence.
      - `message` string, required: What an import would report, exactly as it says it.
    - `measurements` object[], required: Every ceiling this archive could be measured against, each beside the number you sent — so a project at 56 of 512 modules can see its headroom, not just the absence of a refusal.
      - `id` string, required: Stable dotted id — `functions.modules`, `archive.entries`.
      - `label` string, required: What it counts, in one phrase.
      - `measured` number, required: What your archive measured.
      - `limit` number, required: The ceiling it is measured against.
      - `unit` 'bytes' | 'files' | 'characters' | 'milliseconds' | 'requests', required
      - `over` boolean, required: `true` when the number is past its figure — a refusal when `refusals` names it, an advisory when `advisories` does.

## Other responses

- `400`: The body was not multipart/form-data with a `file` field, or `siteId` was not the shape of a site id. A refusal the SCREENS made is a `200` with `data.ok: false`, not a `400`.
- `401`: No bearer credential, or one that did not resolve.
- `403`: Programmatic access is unavailable to this holder, the named workspace is outside the key's scope, or importing is switched off for this account. `workspace_ambiguous` and `workspace_unreachable` also answer here, with the slugs appended to `error.message`.
- `429`: The API key is over its rate limit. Wait for `Retry-After` before retrying.
- `500`: An unexpected server error occurred while screening the archive.

---

[API](https://skmtc.dev/goosybear/apis/goosy-bear-api.md) · [All operations](https://skmtc.dev/goosybear/apis/goosy-bear-api/llms.txt) · [OpenAPI document](https://skmtc.dev/goosybear/apis/goosy-bear-api/revisions/6d82fb124064?raw)
