Create a Validation Record¶
Appends one run result to a validation check's history. Call it on every scan cycle.
Recording a run also updates the check ticket's status from the result, which in turn rolls up to the KSI above it. That is what keeps the compliance picture current without anyone maintaining it by hand.
Before you start: you need a token (Authentication) and the check's ticket_id. That id is not returned by any webhook call, so resolve it once out of band and store it. See Responses.
Minimal call¶
With no validation_status sent, the outcome is inferred: Fail if fail_count is above zero, otherwise Pass.
Fields¶
| Field | Required | Default | Notes |
|---|---|---|---|
ticket_id |
yes | - | The check ticket returned by Upsert a Validation Check. Not its parent |
validation_id |
yes | - | The check's identifier. Used to compose the record key |
validation_datetime |
no | now, UTC | YYYY-MM-DDTHH:MM:SS |
pass_count |
no | 0 |
Integer |
fail_count |
no | 0 |
Integer |
validation_status |
no | inferred | Pass or Fail |
note |
no | - | What the run observed |
reference |
no | - | Link or identifier for the evidence |
validation_key |
no | composed | Override the record key - see below |
These nine fields are the whole writable surface of the run-history table. The check's definition (its name, statement, test, method, evidence type and description) lives on the ticket, not on the run row. Change those with Upsert a Validation Check.
Send the check ticket, not the KSI
The commonest integration mistake is sending the parent KSI ticket id instead of the check's. The container is rejected, not accepted, so a mis-filed run cannot corrupt the rollup. Store the ticket_id that Upsert a Validation Check returns and send that.
What it does to the check's status¶
The check ticket's status is updated from the most recent run by validation_datetime:
| Latest run | Check ticket status |
|---|---|
| Pass | Satisfied |
| Fail | Not Satisfied |
The status does not always land on the same call. The run row and the status update are separate writes, and the status step can run before the new row is visible to it, which leaves the status briefly stale. A scheduled sweep reconciles any check whose status disagrees with its latest run, so the value converges without intervention. Do not assert on the status immediately after posting a run.
Two consequences worth knowing:
- A back-dated correction cannot clobber a newer run. The status follows the newest run, not the one you just posted, so fixing last week's record does not undo this morning's result.
- The KSI above it updates on its own, provided the status-rollup automation is enabled on your tenant. The check's status change triggers it, and the parent recalculates from all of its children.
Adding runs vs. correcting one¶
Records are keyed by validation_key, composed by default as:
To add a run (the normal case), omit validation_key. Each call gets a distinct timestamp, so each call adds a new row to the history. This is what you want on every scan cycle.
To correct a run, send its exact validation_key. The matching record is updated in place, and any field you do not send keeps its current value. So a correction can be as small as:
{
"ticket_id": 2318,
"validation_id": "CNA-DBS-01",
"validation_key": "2318|CNA-DBS-01|2026-08-28T06:00:00",
"note": "Re-reviewed: two findings were on a decommissioned bucket."
}
The counts, status and original timestamp on that record are untouched.
Corrections need the key
Field-level merging applies only when you send a validation_key. Without one the key embeds the current timestamp, so the call is by definition a new run rather than an edit of an existing one, which is the intended behaviour rather than a limitation.
If you send an explicit validation_datetime and call twice with the same timestamp, the second call overwrites the first, because the composed key is identical.
Records cannot be deleted through this endpoint. Remove them in the platform UI.
Allowed values¶
validation_status, exactly one of:
Unlike the fields on Upsert a Validation Check, this one rejects an unrecognised value rather than defaulting to one. Silently recording the wrong outcome of a compliance check is worse than a failed call.
Rejections¶
| Code | Rejected because |
|---|---|
| 51201 | ticket_id missing or not numeric |
| 51202 | validation_id missing |
| 51203 | ticket_id is not a validation check ticket. A control container (a KSI or Rule parent) is rejected here as well as a non-validation ticket |
| 51204 | validation_datetime is not YYYY-MM-DDTHH:MM:SS |
| 51205 | validation_status is not Pass or Fail |
Full example¶
{
"ticket_id": 2318,
"validation_id": "CNA-DBS-01",
"validation_datetime": "2026-08-28T06:00:00",
"pass_count": 41,
"fail_count": 2,
"validation_status": "Fail",
"note": "2 of 43 buckets grant AuthenticatedUsers read on the logs prefix.",
"reference": "https://ci.example.com/runs/8841"
}
A complete integration¶
A scanner onboarding one check and reporting on it every cycle:
Setup, once POST Upsert a Validation Check -> 202
then resolve the check's ticket_id out of band and store it
Every scan cycle POST Create a Validation Record -> 202
with the stored ticket_id
Resolving the id is a one-time setup step per check, not something to do on every run. Look the ticket up by its validation_id or its summary <control_id> | <check_title>, and keep it with your check definition. Re-posting the same validation_id updates that same ticket rather than creating another, so the id you stored stays correct.
Related¶
- Validation Checks: registering the check this reports against
- Steampipe/Powerpipe Stack: the compliance-scanning deployment that feeds machine-based validation