Skip to content

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

{
  "ticket_id": 2318,
  "validation_id": "CNA-DBS-01",
  "pass_count": 43,
  "fail_count": 0
}

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:

{ticket_id}|{validation_id}|{validation_datetime}

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:

Pass | Fail

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.