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

# Creative reviews

> Review the queue of buyer-submitted creatives and approve or reject pending ones

When a buyer submits creatives to your storefront, each immutable content
version lands in your creative review queue as a `pending` row. You inspect the
submitted creative payload and record a terminal decision: `approved` or
`rejected`. An approval can be reused when the identical creative appears on a
later media buy; revised content creates a separate pending review and cannot
inherit the old decision.

The row's `submittedPayload` is the credential-stripped submission snapshot
used for review and delivery. It contains the creative object plus any
request-level `account`, `buyer_ref`, and `context`. Delivery projects the
creative fields downstream, uses `account` for request routing when needed,
and preserves `buyer_ref` and `context` for callback correlation.
`contentDigest` identifies only the creative object, excluding those three
request-level fields, so changing correlation data does not create a different
content decision or overwrite a decided snapshot. Webhook configuration is
stored separately and is never returned in `submittedPayload`.

After approval, the platform separately delivers the creative for each media
buy, package assignment, route, and source. The reusable operator decision is
not proof that a particular assignment reached the source or passed source-side
review.

For when this queue is used — the `creativeApproval` setting, the lowest-risk "review everything" posture, and how you find out something is waiting — see [Reviewing buyer transactions](/v2/storefront/approvals/overview). For **how** a creative is judged against your rules — the rendered-image check and the deterministic locked-asset fidelity guarantees that hold an exact-fidelity product, logo, or cover for human review when it isn't deterministically preserved — see [AI Business Rules](/v2/concepts/ai-business-rules#how-creatives-are-evaluated-against-your-policy).

A review moves through `pending → approved` or `pending → rejected`. The
`revoked` state — pulling a previously-approved creative — is a separate gesture
and cannot be set on the decide endpoint. Each response preserves the legacy
numeric `id` and also returns a collision-free `reviewRef` such as `review:42`.
Use `reviewRef` for review actions. Bare identifiers always select the AdCP
creative-id namespace, including when numeric; `creative:<id>` is its explicit
form. A bare numeric creative id is never interpreted as the surrogate `id`.
When deciding, also echo the rendered row's `contentDigest` as
`expected_content_digest` and, when present, its `sourceTrust.reviewVersionDigest`
as `expected_review_version_digest`. This optimistic version fence prevents a decision
made against an older payload from approving revised creative content. During
the mixed-client rollout, a legacy caller may omit the digest only while that
review remains on its original content generation and the frozen approval work
item still matches. Once content changes, omission is refused with `409
CONFLICT`; reload the row and send its digest.

## Source trust and bounded preview behavior

When the server has receipt-bound provenance, a review version carries a
versioned `sourceTrust` projection. Its classification is one of
`platform_managed_media`, `platform_assembled_static`, or
`buyer_supplied_active`; its evidence records a kind, digest, and policy
version. Legacy or unproven source trust is returned as `null` (and can be
omitted by an N-1 response), not as a classification. An active-source label
is issued only when the server has authenticated buyer custody; tag-shaped data
or platform-supplied metadata alone leaves provenance unavailable. Preview
authority is a separate question: a provider selected for a placement does not
make buyer source safe to execute.

Apostra never renders response-provided image, video, audio, source, or
other network-active asset URLs inside its own pages or a chat host; the
[live preview](#live-preview) runs them only on the separate render origin. `sourceTrust` SHA-256 values are consistency
checks, not signatures or rendering authority. The only response-provided
presentation that may appear locally is bounded plain text from a
`platform_assembled_static` classification, escaped as text. SVG, HTML,
JavaScript, VAST/DAAST, trackers, executable URL assets, and source with
unavailable provenance are never rendered in Apostra. A reviewer can
explicitly request an interactive placement preview; that sends the exact
creative manifest to the authorized provider, where network activity or code
execution may occur. Apostra can receive and retain the complete provider
response as opaque inert data, including HTML or render envelopes, but never
mounts, executes, or prefetches it. Only the separately validated
`externalOpen` envelope can be passed to the standard MCP host `openLink`
bridge after a second explicit reviewer gesture.

Static inspection displays source as text, bounded referenced domains, macros,
and detected capabilities such as scripts, external resources, pixels, frames,
forms, navigation, storage, service workers, and VAST/DAAST wrappers. It does
not fetch, resolve, execute, use a browser, determine runtime behavior, or
claim that a source is safe, clean, or malware-free. A truncated result is
explicitly incomplete.

## Live preview

When the live preview is enabled for your storefront, each pending review row
carries a `renderPreview` link: a signed URL on Apostra's dedicated render
origin (`render.interchange.io`, or `render.staging.interchange.io` in staging)
that shows the exact submitted creative running at placement size. In the
Approvals page, **Show preview** mounts that link inside a sandboxed frame and
**Open full size** opens it in a new tab through your chat host. Third-party
display tags, hosted HTML, VAST video, DAAST audio, and hosted images, video,
and audio all render there.

The render origin is a separate, cookieless site with no access to Apostra,
your chat host, or your session. The creative document is served as an opaque
sandboxed origin, so the tag can load scripts, fire pixels, and animate, but
cannot read anything outside its frame or navigate the page. Click macros in
the tag are rewritten to an interstitial that shows the destination URL instead
of following it. The creative may open new windows, because watching where a
tag sends a user is part of the review, and every window it opens inherits the
sandbox: it runs as an opaque origin that cannot script, read, or navigate the
page you are reviewing from and cannot read cookies or storage itself. It is
still an ordinary navigation to that site, so the site receives whatever
cookies your browser already holds for it, exactly as the tag's own pixels and
scripts do. Treat every window the preview opens as buyer-controlled and do not
sign in or act inside it. Preview macros are filled with test
values (a cachebuster, GDPR applies = `0`, an empty consent string, `1---` for
US privacy).

Each link expires after fifteen minutes and stops working the moment the buyer
resubmits the creative or the review is decided, because the token is bound to
the pending row's
`contentDigest`. Refreshing the queue issues a new one. If you build your own
reviewer UI, embed the URL only in an iframe with
`sandbox="allow-scripts allow-popups"` and
`referrerpolicy="no-referrer"`, or open it in a new tab; never fetch it
server-side or render its contents yourself.

Seeing a creative run is not a safety verdict. The static inspection below the
preview (source, referenced domains, macros, detected behaviours) stays
available so you can judge what the tag does as well as how it looks. Rows
without a `renderPreview` (the origin is not enabled for your environment)
keep source inspection, the
publisher-provider request, and approval exactly as before.

## Observed behaviour and thumbnails

When observation is enabled for your storefront, Apostra runs each newly
submitted creative version once, headlessly, on its own render origin and
records what the creative actually did. The review row's `observation` field
carries the result with a `status` (`queued`, `running`, `completed`,
`timed_out`, or `failed`), the time the run finished, and, once completed, a
record of the registrable domains the creative contacted (with request counts
and kinds), pixel-shaped requests, redirects, popups, attempts to navigate the
top window, console errors, bytes transferred, and any media it fetched. The
record never contains a URL, the render token, or the creative's own bytes;
the pixel and navigation counts are heuristics and are labelled as such.

`thumbnailUrl` is an app-relative, authenticated path to a PNG of the creative
frame from that run, bounded to 600 px on its longest edge. It is served only
to the storefront that owns the review and is never a public URL; a
resubmitted creative gets a fresh observation and the previous picture stops
being served, even to a caller holding the older version's `reviewRef`. A row without an observation reads
`null` for both fields. Observation is evidence from one run at one time from
Apostra's network, not a safety verdict, and it never gates review, the
live preview, or your decision.

In the Approvals page, the row's **Does** line reads from that run when one
has completed: the external domains the creative called (most-called first),
tracking pixels fired, redirects followed and where to, popups and page
navigations attempted, and media fetched, marked "observed" with how long ago.
While a run is queued or running the line says "observing now"; when no run
exists, or it timed out or failed, the line falls back to what can be read
from the source and is marked "from source" with the outcome. The observed
thumbnail appears above the row's stage once it is available, before you open
the live preview.

## Placement-aware previews

When a requested review receives a valid authority-resolved HTTPS preview URL,
the review UI shows its hostname, authority, fidelity, and expiry. An active
source or one with unavailable provenance may be sent to the authorized
provider only after the reviewer requests an interactive preview. A second
**View provider preview** gesture
revalidates the URL/origin/expiry and calls the standard MCP host `openLink`
bridge; the widget does not render an anchor, iframe, or provider bytes. The
destination may execute code, track, or redirect outside Apostra. The
separate server-owned, zero-egress artifact renderer remains required for an
inline rendered preview. Preview availability never prevents an approval.

Current review rows can include `previewContexts`, one opaque context for each
exact package and placement that can be resolved without guessing. Pass a
context's `contextRef`, the row's `contentDigest`, and its
`sourceTrust.reviewVersionDigest` when it is present to the preview endpoint.
The server issues `interactivePreviewAvailable` when the deployment kill switch
allows interactive preview; only `true` offers the explicit provider-dispatch
action. A missing or `false` value is fail-closed: the review stays available
for source inspection and approval or rejection.
Legacy, standalone, defaulted, ambiguous, stale, or superseded routes have no
preview context and remain source-inspection only.

Preview authority is placement-specific. A publisher-designated preview
provider is preferred for its exact placement, followed by that placement's
digest-verified presentation metadata. Provider output is never rendered inside
Apostra, including secondary serving-platform output. When no authorized
external URL is available, the reviewer sees the submitted manifest or inert
tag/VAST source and an explicit no-preview state. Apostra never executes
submitted tag code or remote VAST to manufacture an ad screenshot.

## Buyer status and callbacks

Buyer agents submit creatives with AdCP `sync_creatives`. In manual-review mode the first response can return `pending_review` for each queued creative:

```json theme={null}
{
  "creatives": [
    {
      "creative_id": "cr_abc123",
      "action": "created",
      "status": "pending_review"
    }
  ]
}
```

If the buyer calls `sync_creatives` again for unchanged content that has already
been approved, the storefront reuses the operator decision. The per-creative
status remains `processing` until every required source confirms the current
media-buy assignment. It returns `approved` only with that delivery evidence.
An operator rejection returns `rejected` with `action: "failed"` and the
reviewer note as `message` when one was supplied.

Buyers can attach AdCP push-notification config to the original `sync_creatives` request:

```json theme={null}
{
  "creatives": [
    {
      "creative_id": "cr_abc123",
      "format": "video",
      "media_url": "https://cdn.acme-brand.example/spots/launch-30s.mp4"
    }
  ],
  "push_notification_config": {
    "url": "https://buyer.example.com/adcp/tasks",
    "token": "buyer-echo-token",
    "authentication": {
      "schemes": ["Bearer"],
      "credentials": "buyer-webhook-token"
    }
  }
}
```

Use `push_notification_config` for canonical AdCP requests. The storefront also accepts `pushNotificationConfig` from clients that pass camelCase JSON through a REST/MCP proxy. Callback URLs must be public HTTPS endpoints; localhost, private-network, link-local, metadata, and internal hostnames are rejected. Supported authentication schemes are `Bearer` and `HMAC-SHA256`.

When the operator rejects the creative, or when an approved creative reaches a
terminal delivery outcome, the buyer webhook receives a signed ADCP task event
with `status: "completed"`. Each buyer operation keeps its own callback and
correlation fields. Failed webhook deliveries remain pending and are retried;
one operation cannot overwrite another operation's callback.

### Per-creative webhook outcomes

The task envelope is always `status: "completed"` — the per-creative outcome lives in `result.creatives[]` as an `action` and `status` pair. **Read both fields:** `action: "failed"` on its own does not mean the operator declined the creative.

| Outcome                             | `action`  | `status`   | Other fields                                                                                                    |
| ----------------------------------- | --------- | ---------- | --------------------------------------------------------------------------------------------------------------- |
| Approved and delivered to inventory | `updated` | `approved` | —                                                                                                               |
| Operator rejected                   | `failed`  | `rejected` | `message`: reviewer note, when supplied                                                                         |
| Approved but could not be delivered | `failed`  | `approved` | `errors[].code`: `delivery_failed`; `message`: "Creative was approved but could not be delivered to inventory." |

`action: "failed"` with `status: "approved"` means the operator approved the creative but the storefront could not deliver it to every required inventory source after bounded retries — the creative is **not** live. Treat it as an actionable delivery failure (re-submit, or contact the operator), distinct from a content rejection: the discriminator is `errors[].code: "delivery_failed"` together with `status: "approved"`. The inventory source is never named, and a permanent failure and a retry-deadline failure are indistinguishable on the wire — both surface as this row. Partial multi-source delivery remains incomplete and is never rolled up as approved.

An observable source task may remain `processing` beyond the 30-minute
network and route retry window while a human or ad server reviews it. The
storefront continues polling that durable task for up to seven days from the
first source delivery attempt. If the source still has not returned a terminal
decision, the storefront records the terminal `delivery_failed` outcome above
and sends the buyer callback; a source task cannot remain callback-silent
indefinitely.

<Note>
  The creative review row returned to storefront operators omits `push_notification_config` and `pushNotificationConfig` from `submittedPayload` so webhook credentials are not exposed in the queue UI or REST responses.
</Note>

All examples use the storefront base URL:

```
https://api.apostra.com/api/v2/storefront
```

Authenticate every request with `Authorization: Bearer $SCOPE3_API_KEY`.

## Task reference

<CardGroup cols={2}>
  <Card title="List reviews" href="/v2/storefront/creative-reviews/tasks/list-reviews" icon="list">
    `GET /creative-reviews` — the review queue, newest first
  </Card>

  <Card title="Get a review" href="/v2/storefront/creative-reviews/tasks/get-review" icon="magnifying-glass">
    `GET /creative-reviews/{creativeId}` — one creative review row
  </Card>

  <Card title="Preview a review" href="/v2/storefront/creative-reviews/tasks/preview-review" icon="eye">
    `POST /creative-reviews/{creativeId}/preview` — resolve a placement-aware preview
  </Card>

  <Card title="Decide a review" href="/v2/storefront/creative-reviews/tasks/decide-review" icon="gavel">
    `POST /creative-reviews/{creativeId}/decide` — approve or reject
  </Card>
</CardGroup>
