# Integration event verification

> Prove that the exact product events your application test sent arrived, under the right identity and against your tracking plan.

Canonical page: https://anectico.com/docs/reference/integration-verification/


After your agent wires product events into an application, it runs a controlled test and then asks
Anectico one question: did **these exact events** arrive, under **this identity**, and do they
match **this tracking plan revision**? The verification read answers that question for up to 20
samples at a time. It never sends events, changes nothing, stores no report and runs no model; it
reports what the readable evidence shows, check by check.

The same request works on every surface:

| Surface | How |
| --- | --- |
| REST | `POST /api/v1/instrumentation/events/verify` with the JSON request as the body |
| MCP | `execute_read_action` with action `verify_integration_events` and the JSON request as arguments |
| CLI | `anectico doctor events --file request.json` (plus the usual `--project` and `--json`) |

Project-bound keys may omit `project_id`; naming a different project is refused. The CLI refuses a
file whose `project_id` contradicts `--project`.

## The request

```json
{
  "project_id": "2d3c9f8e-1a1b-4c5d-9e0f-123456789abc",
  "window_start": "2026-10-15T09:00:00Z",
  "window_end": "2026-10-15T10:00:00Z",
  "samples": [
    {
      "event": "checkout_completed",
      "message_id": "test-run-42-checkout",
      "source_id": "7f1e2d3c-4b5a-4968-8776-5544332211aa",
      "distinct_id": "test-person-42",
      "session_id": "test-session-42",
      "groups": {"company": "acme"}
    }
  ],
  "tracking_plan_id": "c0ffee00-1234-4abc-8def-0123456789ab",
  "tracking_plan_revision": "3"
}
```

| Field | Meaning and bounds |
| --- | --- |
| `window_start`, `window_end` | Event-time window `[start, end)`, millisecond precision, at most one hour. |
| `samples` | 1 to 20 samples; the answer keeps their order. Each `(event, message_id, source_id)` appears once. |
| `samples[].event` | Exact event name, at most 200 bytes. |
| `samples[].message_id` | The exact `message_id` your application sent with the event. Send your own stable ID in tests. |
| `samples[].source_id` | The ID of the source that admitted the event: the sending key's `source_id`, or the project's default source when the key names none (see [native intake keys](/docs/instrument/native-intake-keys)). It is taken from the credential, never from the event. |
| `samples[].distinct_id` | The identity the application declared. It is checked as an alias, never treated as a person ID. |
| `samples[].session_id` | Optional. Present means "check it"; an empty string checks for an empty session. Omit it to skip the check. |
| `samples[].groups` | Optional. The account the event must carry: group type to group key, as the application sent them in `properties.$groups` or as the SDK attached them after its `group` call. Up to 20 entries, each a printable string of at most 200 bytes. Every named entry is compared; accounts the event carries that you do not name are ignored. Omit it to skip the account check. |
| `samples[].property_checks` | Optional, only without a tracking plan: up to 20 `{ "key", "required", "types" }` checks. `required` must be written as `true` or `false`; `types` is a set of `null`, `boolean`, `number`, `string`, `array`, `object`. The key `$groups` is refused: Anectico keeps the account an event carries as its account, not as a property, so check it with `groups`. |
| `tracking_plan_id`, `tracking_plan_revision` | Optional pair: the plan and an exact positive revision written as a decimal string. Neither half alone is valid. The latest revision is never substituted. |

Identify and group-identify calls store no event row, so they cannot be selected; verify the events
that follow them instead.

## The answer

Every check has a `state` and, unless it matched, a `reason`:

| State | Meaning |
| --- | --- |
| `observed_match` | The readable evidence matches the request. |
| `mismatch` | The evidence was read and differs (for example `different_distinct_id`, `type_mismatch`, `required_missing`, `event_not_declared`). |
| `not_observed` | A complete, readable read of the whole window found no such event (or Anectico holds no exact alias for the declared identity). |
| `withheld` | Your key lacks the scope or the project's content policy withholds it. The reason names which. Never a sign of absence. |
| `unavailable` | A limit, a failure or the edge of readable history prevented the check. The reason names which. Never a sign of absence. |
| `ambiguous` | More than one stored event matches the selection; none is chosen for you. |

Each result also has an `outcome`: `verified` (every check observed_match), `failed` (a mismatch or
a not_observed event) or `undetermined` (anything else). `population` counts requested samples:
`requested`, `observed`, `not_observed`, `ambiguous`, `undetermined`, `verified` and `failed`.

Per sample you get:

- `event`, plus `observed` — the stored event's name, message ID, source, origin
  (`browser_product`, `server_product`, `mobile_product`, `otlp` or `platform_product`), distinct ID,
  session, event time and trusted receipt time — only when exactly one event matched.
- `distinct_id` and, when requested, `session_id` checks against that event.
- `groups`, when requested: `observed_match` when the stored event carries every named group type
  with exactly that key, otherwise `mismatch` with `group_missing` or `different_group_key`. The
  stored accounts themselves are never returned. A plan that declares `$groups` as a property gets
  `not_supported` (`account_memberships_use_groups`) for that key, never `absent`.
- `identity` — with `persons:read`, the canonical `person_id` the declared alias resolves to.
- `plan_declaration` — with a plan, whether that exact revision declares the event. An unrelated or
  empty plan never matches; a plan that declares the event with no properties does.
- `properties` — one entry per declared or requested key: `required`, `expected_types`,
  `expectation_source` (`tracking_plan` or `request`), `state`, `reason`, `observed_presence`
  (`present`/`absent`) and the literal `observed_type`. Values, undeclared key names and hidden-key
  counts are never returned. Extra undeclared properties are not a mismatch.

The response also carries `checked_at`, the `read_cutoff` (events received after it are not read;
it is not a snapshot or delivery watermark), the read's start and finish clocks, the `plan` that
was loaded with its `definition_hash`, `limits`, plain `limitations`, `read_coverage` (the window
actually read and the readable-history floor) and an always-empty `links`: there is no proof page
for this read.

## Scopes

| Scope | What it adds |
| --- | --- |
| `analytics:read` | Required. Event, distinct ID, session and account (`groups`) checks. (MCP also needs `mcp:read`.) |
| `tracking_plans:read` | Inspecting the pinned plan. Without it the plan and declarations are `withheld`. |
| `persons:read` | Canonical identity. Without it identity is `withheld`, and a stored identity that differs from the declared one is reported only as a mismatch. |
| `agents:content:read` | Property checks. Without it every property check is `withheld`. Sensitive declarations, content policy and content history can still withhold a property. |

A withheld check never lets a sample verify. If a scope or the project's policy changes while the
read runs, or the selected events change (for example a person is erased), no report is released:
the call fails and you start a fresh read.

## Limits

The limits are fixed by Anectico and returned in every answer under `limits`:

| Limit | Value |
| --- | --- |
| Window | 1 hour |
| Samples | 20 |
| Property checks per sample | 20 |
| Rows scanned | 10,000,000 |
| Bytes scanned | 1 GiB |
| Memory | 512 MiB |
| Candidate events returned | 200 |
| Returned bytes | 4 MiB |
| Execution time | 8 seconds |
| Concurrent verifications per project | 2 |

The read examines every retained event of the selected names in the project, not only the window,
so a project with several million retained events of those names can reach the byte or row limit;
choose fewer distinct event names per call in that case.

When a read reaches a limit, every sample is `unavailable` with a `limit_…` reason and
`limits.limit_reached` is true; nothing is reported as `not_observed`. A third concurrent
verification for the same project is refused (HTTP 429); retry shortly. Normal rate limits and agent
budgets apply; there is no separate fee.

## Using it in a test

1. Generate a fresh `message_id` for each test event and keep the `distinct_id` and session your
   test uses.
2. Run the test, then wait for the capture acknowledgement. Acknowledgement means the event was
   accepted, not that it can be read yet.
3. Verify. If an event is `not_observed` right after the test, wait briefly and verify again with
   the **same** request. Do not resend the event to make it appear.
4. Report exactly what was verified. A verified sample says nothing about other events, other people
   or whether everything your application emitted arrived.

To verify feature-flag exposures against their flag definition version, see
[feature-flag exposure verification](/docs/reference/flag-integration-verification). To verify
either job through one request with a single verdict, see
[integration job verification](/docs/reference/integration-job-verification).

Two sources that send the same event name, timestamp, distinct ID and message ID keep one stored
event, so verifying one source can report `not_observed` because the other source's copy was kept.
Use a unique `message_id` per source in tests.

For the stages between your code and stored evidence, what a verified sample does not prove and
what has been run, see [What integration verification proves and what it does not](/docs/reference/integration-verification-limits).

See also [verify your setup](/docs/start/verify-setup), [tracking plans](/docs/investigate/tracking-plans)
and [event property schemas](/docs/investigate/event-schema), which describe whole-project
observation rather than one exact event.

## Experiment receipt handoff

Read one launched subject through `get_experiment_delivery_evidence` in `execute_read_action`, or
`anectico experiments delivery-evidence KEY --revision REV --assignment-id ID --file outcomes.json`.
The equivalent REST read is `POST /api/v1/projects/{projectId}/experiments/{key}/delivery-evidence`.
It requires `experiments:read`; canonical attribution and the alternative `distinct_id` selector
require `persons:read`. This read returns existing receipts and the frozen outcome declaration;
it does not run the independent capture checks on this page.

Preserve the application's actual event, declared identity, source, message ID and optional
session and run selectors. Receipt `(source_id,event_id)` must match sample `(source_id,message_id)`;
the observed event must match the frozen launched outcome and the sample's resolved person must
match readable assignment attribution. Without that sample or attribution, do not report an
outcome as verified. A provisional receipt is not stored-capture proof; reconciled is an existing
settlement rather than a fresh read. No selected outcomes means assignment and exposure evidence
only. See [subject delivery evidence](/docs/investigate/product-experiments#read-one-tested-subjects-delivery-evidence)
for states, limits and the isolated smoke-test boundary. The composed experiment verification job
is not available; use the two explicit reads.


## Survey receipt handoff

Read `get_survey_delivery_evidence` through `execute_read_action`, or
`anectico surveys delivery-evidence SURVEY_ID --revision REV --person TEST_ID
--start RFC3339 --end RFC3339`. REST uses a semantic POST read at
`/api/v1/projects/{projectId}/surveys/{surveyId}/delivery-evidence`. The exact
launched revision is a decimal string, and the receipt-time window is at most one
hour. Optional `shown_id` and `outcome_id` preserve presence and compare the caller's
IDs only under content authority.

Retained offers, explicit displays, first contact and outcomes are distinct facts;
independent stored events are another check. Only an established emission binding
can supply a message ID or response group. A historical unbound stage cannot be
reconstructed. An incomplete subject lookup, unreadable history, failed source or
budget limit is undetermined, never proof that no response arrived. No answer values
or proof page are returned. Current and eligible earlier identities are checked together;
identity correction, erased or unknown history and population limits can refuse that check.
An unavailable lookup is distinct from a genuinely unresolved visitor. This is a selected diagnostic, with current permissions
and erasure checks; it neither delivers a survey nor proves every answer arrived.
See [visitor delivery evidence](/docs/investigate/surveys#read-one-tested-visitors-delivery-evidence)
for the selection, conditional scopes, limits and isolated qualification boundary.
