Skip to content
Reference / API27 endpoints · 6 events · Sheet 01 of 12 · Updated 20 Sep 2026
API

Build against Veil

The whole of Veil's second front door: authenticate, open a case, upload documents, read what came back and hear about it when it changes. Every mutating endpoint is a thin adapter over the same function the dashboard calls, so there is no second implementation of any rule.

By Magdalena Góralczyk, Partner, Head of Data Protection.

Base URL
https://veil.pritect.ai/api/v1
Version
1.0.0
OpenAPI
3.1.0
Reference / APISheet 02 of 12 · Updated 20 Sep 2026
Authentication

One bearer key, one machine identity

Every request carries an API key as a bearer token. A key begins with pv_live_ and is shown once, in the Developer area, when it is created. Veil stores only a hash of it, so a key that is lost is replaced rather than recovered.

A key is not a shared credential with a role attached. It has its own identity inside your organisation, so row level security applies to every read it makes, and the audit log, which holds no personal data, records what the key did rather than who created it. Revoking a key stops it immediately and leaves its history intact.

A key holds a role, capped at the role of the person who created it and never owner, and a set of scopes. Scopes narrow the role; they never widen it. A key whose role is member and whose scopes include releasing a case still cannot release one, because the check that matters happens in the database.

Every request
Authorization: Bearer pv_live_EXAMPLE_KEY_VALUE_NOT_A_REAL_KEY
Content-Type: application/json
Reference / API10 scopes · Sheet 03 of 12 · Updated 20 Sep 2026
Scopes

Ten of them, and they only ever narrow

A request outside the key's scopes is refused with a fixed code, before anything is written. Give a key the smallest set its integration needs: a key that can read cases and download bundles cannot open, advance or release one, and only a key holding cases:erase can abandon a case and erase its material.

  • cases:read
  • cases:write
  • cases:release
  • cases:erase
  • documents:read
  • documents:write
  • entities:read
  • entities:write
  • bundle:read
  • webhooks:read
Reference / API27 codes · Sheet 04 of 12 · Updated 20 Sep 2026
Errors

One shape for every failure

The code is drawn from a closed vocabulary, so a client branches on it rather than on prose. The message is a fixed English sentence and never quotes the input: a validation failure names the field that was wrong and never the value that was in it, because that value may be the personal data the whole product exists to keep in one place.

The request id is generated per request, returned in the body and in an X-Request-Id header, and appears in the one log line the request writes. It is what to quote in a support mail, and quoting it does not mean quoting anything else.

Every failure
{
  "error": {
    "code": "case_not_released",
    "message": "The case has not been released yet.",
    "request_id": "01J8ZQ9C4R7K2M6N0P3T5V8W1X"
  }
}
Every error code the API answers with
StatusCodeMessage
400idempotency_key_requiredThis endpoint requires an Idempotency-Key header.
400invalid_cursorThe cursor is not one this API issued.
400invalid_requestThe request does not match the contract.
401invalid_keyThe API key is missing, unknown, revoked or expired.
403api_access_unavailableThis organisation's plan does not currently include API access.
403decision_requires_a_personThis decision discloses personal data and is made by a person in the Veil dashboard.
403insufficient_scopeThe API key does not hold the scope this endpoint requires.
403not_authorizedThe API key's role does not allow this.
404not_foundNo such resource.
409bundle_not_readyThe case has not been packaged, so it has no bundle yet.
409case_not_draftThe case has left draft, so its intake can no longer be changed.
409case_not_readyThe case is not ready to be released.
409case_not_releasedThe case has not been released yet.
409case_purgedThe case has been purged, so its documents and reports are gone; its record carries the tombstone.
409entity_not_editableThe entity's current state does not allow this change.
409idempotency_key_reusedThis Idempotency-Key was already used with a different request body.
409plan_limit_reachedA plan limit refused this request.
409report_not_readyThe case has not been packaged, so it has no quality report yet.
409review_not_openThe case is not in review, so entity decisions cannot be changed.
409spot_check_miss_stalledThe spot check reported a miss whose reprocessing failed; an admin can retry it in the dashboard.
409spot_check_miss_unresolvedThe spot check reported a miss and the case is still being reprocessed.
409spot_check_pendingThe case's spot check has pages that nobody has answered yet.
409transition_refusedThe case's current state does not allow this.
422anchor_not_derivableNo searchable variant could be derived from that anchor.
429rate_limitedToo many requests for this API key in the current window.
500internal_errorThe request could not be completed.
503dependency_unavailableA dependency this endpoint needs is unavailable.
Reference / APISheet 05 of 12 · Updated 20 Sep 2026
Pagination

Cursors, never offsets

Every list is cursor paginated. A response carries the rows and a next cursor, which is opaque: it encodes an instant and an id, and the only correct thing to do with one is to send it back.

It is not an offset, and that is a correctness decision rather than a performance one. A case list mutates under a client that is paginating through it, and an offset silently skips rows when it does.

Every list
{
  "data": [
    "..."
  ],
  "next_cursor": "b3JkZXI6MjAyNi0wOS0wNFQwOToxMjo0NFo"
}
Reference / APISheet 06 of 12 · Updated 20 Sep 2026
Idempotency

A retry that times out creates one case

An Idempotency-Key header is accepted on every request that creates or advances something, and required on case creation. Veil stores the key with your organisation, the endpoint, a hash of the request body and the response, for 24 hours.

A replay with the same key and the same body returns the stored response and says so in an Idempotency-Replayed header. A replay with the same key and a different body is refused. That is what makes a retrying integration safe: a client that times out creating a case and tries again gets one case, not two, and does not spend a second slot against its plan.

On a create
Idempotency-Key: 01J8ZQ9C4R7K2M6N0P3T5V8W1X
Idempotency-Replayed: true
Reference / APISheet 07 of 12 · Updated 20 Sep 2026
Rate limits

A window, a budget, and four headers

A key may make 120 requests in a 60 second window. Two endpoints start work that costs model calls and take a smaller budget of 10 in the same window.

  • POST /cases
  • POST /cases/{caseId}/process

Every response carries RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset and, once a limit is reached, Retry-After. Honour that last one: a client that retries immediately spends its next window on refusals.

Reference / API27 endpoints · Sheet 08 of 12 · Updated 20 Sep 2026
Endpoints

All 27 of them

Each one names the function behind it, because every mutating endpoint is a thin adapter over the same transition function the dashboard calls.

Cases, documents and entities

POSThttps://veil.pritect.ai/api/v1/cases

Create a case

Scope
cases:write
Behind it
create_case
Idempotency key
required
Request body, CreateCaseRequest
{
  "external_reference": "REQ-EXAMPLE-0042"
}
Response 201, Case
{
  "id": "8c1f0b2a-0000-4000-8000-00000000c001",
  "org_id": "8c1f0b2a-0000-4000-8000-00000000a001",
  "reference": "VC-2026-0007",
  "status": "draft",
  "jurisdiction_packs": [
    "gdpr-eu"
  ],
  "locked_at": "2026-09-04T09:12:44Z",
  "released_at": "2026-09-04T09:12:44Z",
  "purge_at": "2026-09-04T09:12:44Z",
  "created_at": "2026-09-04T09:12:44Z",
  "updated_at": "2026-09-04T09:12:44Z",
  "external_reference": "REQ-EXAMPLE-0042",
  "tombstone": {}
}

Failures: 400 idempotency_key_required, invalid_request; 401 invalid_key; 403 api_access_unavailable, insufficient_scope, not_authorized; 404 not_found; 409 idempotency_key_reused, transition_refused; 429 rate_limited; 500 internal_error.

GEThttps://veil.pritect.ai/api/v1/cases

List cases

Scope
cases:read
Behind it
RLS read
Idempotency key
not accepted
Paging
limit and cursor
Response 200
{
  "data": [
    {
      "id": "8c1f0b2a-0000-4000-8000-00000000c001",
      "org_id": "8c1f0b2a-0000-4000-8000-00000000a001",
      "reference": "VC-2026-0007",
      "status": "draft",
      "jurisdiction_packs": [
        "gdpr-eu"
      ],
      "locked_at": "2026-09-04T09:12:44Z",
      "released_at": "2026-09-04T09:12:44Z",
      "purge_at": "2026-09-04T09:12:44Z",
      "created_at": "2026-09-04T09:12:44Z",
      "updated_at": "2026-09-04T09:12:44Z",
      "external_reference": "REQ-EXAMPLE-0042",
      "tombstone": {}
    }
  ],
  "next_cursor": null
}

Failures: 400 invalid_cursor, invalid_request; 401 invalid_key; 403 api_access_unavailable, insufficient_scope, not_authorized; 404 not_found; 429 rate_limited; 500 internal_error.

GEThttps://veil.pritect.ai/api/v1/cases/{caseId}

Read a case with its progress rollup

Scope
cases:read
Behind it
RLS read plus case_progress
Idempotency key
not accepted
Response 200, CaseDetail
{
  "id": "8c1f0b2a-0000-4000-8000-00000000c001",
  "org_id": "8c1f0b2a-0000-4000-8000-00000000a001",
  "reference": "VC-2026-0007",
  "status": "draft",
  "jurisdiction_packs": [
    "gdpr-eu"
  ],
  "locked_at": "2026-09-04T09:12:44Z",
  "released_at": "2026-09-04T09:12:44Z",
  "purge_at": "2026-09-04T09:12:44Z",
  "created_at": "2026-09-04T09:12:44Z",
  "updated_at": "2026-09-04T09:12:44Z",
  "external_reference": "REQ-EXAMPLE-0042",
  "tombstone": {},
  "progress": {
    "status": "draft",
    "total_documents": 1,
    "ingested_documents": 1,
    "failed_documents": 1,
    "withheld_documents": 1,
    "queued_jobs": 1,
    "running_jobs": 1,
    "dead_jobs": 1,
    "health_flagged": false,
    "updated_at": "2026-09-04T09:12:44Z"
  }
}

Failures: 400 invalid_request; 401 invalid_key; 403 api_access_unavailable, insufficient_scope, not_authorized; 404 not_found; 429 rate_limited; 500 internal_error.

DELETEhttps://veil.pritect.ai/api/v1/cases/{caseId}

Delete a draft case, or abandon and purge any other case

Scope
cases:write; cases:erase as well to abandon a case that is not a draft
Behind it
delete_case for a draft; abandon_case for any other status, confirmed by confirm_reference
Idempotency key
not accepted

Answers 204 with no body. Delete a draft case, or abandon and purge any other case

Failures: 400 invalid_request; 401 invalid_key; 403 api_access_unavailable, insufficient_scope, not_authorized; 404 not_found; 409 case_purged, transition_refused; 429 rate_limited; 500 internal_error.

POSThttps://veil.pritect.ai/api/v1/cases/{caseId}/subject-anchors

Declare a subject anchor

Scope
cases:write
Behind it
deriveAnchors then add_subject_anchor
Idempotency key
accepted
Request body, CreateAnchorRequest
{
  "kind": "name",
  "value": "subject@example.com"
}
Response 201, SubjectAnchor
{
  "id": "8c1f0b2a-0000-4000-8000-00000000c001",
  "case_id": "8c1f0b2a-0000-4000-8000-00000000c001",
  "kind": "name",
  "source": "input",
  "derivation": "derivation",
  "derived_from": "8c1f0b2a-0000-4000-8000-00000000b001",
  "value": "subject@example.com",
  "created_at": "2026-09-04T09:12:44Z"
}

Failures: 400 invalid_request; 401 invalid_key; 403 api_access_unavailable, insufficient_scope, not_authorized; 404 not_found; 409 idempotency_key_reused, transition_refused; 422 anchor_not_derivable; 429 rate_limited; 500 internal_error.

GEThttps://veil.pritect.ai/api/v1/cases/{caseId}/subject-anchors

List subject anchors, input and derived distinguished

Scope
cases:read
Behind it
RLS read
Idempotency key
not accepted
Paging
limit and cursor
Response 200
{
  "data": [
    {
      "id": "8c1f0b2a-0000-4000-8000-00000000c001",
      "case_id": "8c1f0b2a-0000-4000-8000-00000000c001",
      "kind": "name",
      "source": "input",
      "derivation": "derivation",
      "derived_from": "8c1f0b2a-0000-4000-8000-00000000b001",
      "value": "subject@example.com",
      "created_at": "2026-09-04T09:12:44Z"
    }
  ],
  "next_cursor": null
}

Failures: 400 invalid_cursor, invalid_request; 401 invalid_key; 403 api_access_unavailable, insufficient_scope, not_authorized; 404 not_found; 429 rate_limited; 500 internal_error.

DELETEhttps://veil.pritect.ai/api/v1/cases/{caseId}/subject-anchors/{anchorId}

Remove a subject anchor

Scope
cases:write
Behind it
remove_subject_anchor
Idempotency key
not accepted

Answers 204 with no body. Remove a subject anchor

Failures: 400 invalid_request; 401 invalid_key; 403 api_access_unavailable, insufficient_scope, not_authorized; 404 not_found; 409 transition_refused; 429 rate_limited; 500 internal_error.

POSThttps://veil.pritect.ai/api/v1/cases/{caseId}/documents/upload-url

Sign a single-object upload URL

Scope
documents:write
Behind it
storage.createSignedUploadUrl on originals
Idempotency key
accepted
Request body, UploadUrlRequest
{
  "filename": "statement.pdf"
}
Response 200, UploadUrl
{
  "path": "8c1f0b2a-0000-4000-8000-00000000a001/8c1f0b2a-0000-4000-8000-00000000c001/statement.pdf",
  "token": "EXAMPLE_UPLOAD_TOKEN_NOT_A_REAL_TOKEN",
  "upload_url": "https://storage.example.com/object/upload/sign/originals/statement.pdf",
  "filename": "statement.pdf"
}

Failures: 400 invalid_request; 401 invalid_key; 403 api_access_unavailable, insufficient_scope, not_authorized; 404 not_found; 409 case_not_draft, idempotency_key_reused, transition_refused; 429 rate_limited; 500 internal_error.

POSThttps://veil.pritect.ai/api/v1/cases/{caseId}/documents

Register an uploaded object as a document

Scope
documents:write
Behind it
create_document
Idempotency key
accepted
Request body, CreateDocumentRequest
{
  "path": "8c1f0b2a-0000-4000-8000-00000000a001/8c1f0b2a-0000-4000-8000-00000000c001/statement.pdf",
  "filename": "statement.pdf",
  "mime_type": "application/pdf",
  "size_bytes": 184320
}
Response 201, Document
{
  "id": "8c1f0b2a-0000-4000-8000-00000000c001",
  "case_id": "8c1f0b2a-0000-4000-8000-00000000c001",
  "parent_document_id": "8c1f0b2a-0000-4000-8000-00000000d000",
  "origin": "upload",
  "depth": 0,
  "filename": "statement.pdf",
  "mime_type": "application/pdf",
  "size_bytes": 184320,
  "status": "uploaded",
  "withheld_reason": "withheld_reason",
  "ingested_at": "2026-09-04T09:12:44Z",
  "created_at": "2026-09-04T09:12:44Z"
}

Failures: 400 invalid_request; 401 invalid_key; 403 api_access_unavailable, insufficient_scope, not_authorized; 404 not_found; 409 idempotency_key_reused, transition_refused; 429 rate_limited; 500 internal_error.

GEThttps://veil.pritect.ai/api/v1/cases/{caseId}/documents

List the case's documents

Scope
documents:read
Behind it
RLS read
Idempotency key
not accepted
Paging
limit and cursor
Response 200
{
  "data": [
    {
      "id": "8c1f0b2a-0000-4000-8000-00000000c001",
      "case_id": "8c1f0b2a-0000-4000-8000-00000000c001",
      "parent_document_id": "8c1f0b2a-0000-4000-8000-00000000d000",
      "origin": "upload",
      "depth": 0,
      "filename": "statement.pdf",
      "mime_type": "application/pdf",
      "size_bytes": 184320,
      "status": "uploaded",
      "withheld_reason": "withheld_reason",
      "ingested_at": "2026-09-04T09:12:44Z",
      "created_at": "2026-09-04T09:12:44Z"
    }
  ],
  "next_cursor": null
}

Failures: 400 invalid_cursor, invalid_request; 401 invalid_key; 403 api_access_unavailable, insufficient_scope, not_authorized; 404 not_found; 429 rate_limited; 500 internal_error.

GEThttps://veil.pritect.ai/api/v1/cases/{caseId}/documents/{documentId}

Read one document

Scope
documents:read
Behind it
RLS read
Idempotency key
not accepted
Response 200, Document
{
  "id": "8c1f0b2a-0000-4000-8000-00000000c001",
  "case_id": "8c1f0b2a-0000-4000-8000-00000000c001",
  "parent_document_id": "8c1f0b2a-0000-4000-8000-00000000d000",
  "origin": "upload",
  "depth": 0,
  "filename": "statement.pdf",
  "mime_type": "application/pdf",
  "size_bytes": 184320,
  "status": "uploaded",
  "withheld_reason": "withheld_reason",
  "ingested_at": "2026-09-04T09:12:44Z",
  "created_at": "2026-09-04T09:12:44Z"
}

Failures: 400 invalid_request; 401 invalid_key; 403 api_access_unavailable, insufficient_scope, not_authorized; 404 not_found; 429 rate_limited; 500 internal_error.

DELETEhttps://veil.pritect.ai/api/v1/cases/{caseId}/documents/{documentId}

Remove a document from a draft case

Scope
documents:write
Behind it
remove_document
Idempotency key
not accepted

Answers 204 with no body. Remove a document from a draft case

Failures: 400 invalid_request; 401 invalid_key; 403 api_access_unavailable, insufficient_scope, not_authorized; 404 not_found; 409 transition_refused; 429 rate_limited; 500 internal_error.

POSThttps://veil.pritect.ai/api/v1/cases/{caseId}/documents/{documentId}/withhold

Withhold a document from the bundle, with a reason

Scope
documents:write
Behind it
withhold_document
Idempotency key
accepted
Request body, WithholdDocumentRequest
{
  "reason": "reason"
}
Response 200, Document
{
  "id": "8c1f0b2a-0000-4000-8000-00000000c001",
  "case_id": "8c1f0b2a-0000-4000-8000-00000000c001",
  "parent_document_id": "8c1f0b2a-0000-4000-8000-00000000d000",
  "origin": "upload",
  "depth": 0,
  "filename": "statement.pdf",
  "mime_type": "application/pdf",
  "size_bytes": 184320,
  "status": "uploaded",
  "withheld_reason": "withheld_reason",
  "ingested_at": "2026-09-04T09:12:44Z",
  "created_at": "2026-09-04T09:12:44Z"
}

Failures: 400 invalid_request; 401 invalid_key; 403 api_access_unavailable, insufficient_scope, not_authorized; 404 not_found; 409 idempotency_key_reused, transition_refused; 429 rate_limited; 500 internal_error.

POSThttps://veil.pritect.ai/api/v1/cases/{caseId}/process

Lock intake, which starts processing

Scope
cases:write
Behind it
lock_case_intake
Idempotency key
accepted
Response 200, Case
{
  "id": "8c1f0b2a-0000-4000-8000-00000000c001",
  "org_id": "8c1f0b2a-0000-4000-8000-00000000a001",
  "reference": "VC-2026-0007",
  "status": "draft",
  "jurisdiction_packs": [
    "gdpr-eu"
  ],
  "locked_at": "2026-09-04T09:12:44Z",
  "released_at": "2026-09-04T09:12:44Z",
  "purge_at": "2026-09-04T09:12:44Z",
  "created_at": "2026-09-04T09:12:44Z",
  "updated_at": "2026-09-04T09:12:44Z",
  "external_reference": "REQ-EXAMPLE-0042",
  "tombstone": {}
}

Failures: 400 invalid_request; 401 invalid_key; 403 api_access_unavailable, insufficient_scope, not_authorized; 404 not_found; 409 idempotency_key_reused, transition_refused; 429 rate_limited; 500 internal_error.

GEThttps://veil.pritect.ai/api/v1/cases/{caseId}/entities

List the case's entities

Scope
entities:read
Behind it
RLS read
Idempotency key
not accepted
Paging
limit and cursor
Response 200
{
  "data": [
    {
      "id": "8c1f0b2a-0000-4000-8000-00000000c001",
      "case_id": "8c1f0b2a-0000-4000-8000-00000000c001",
      "kind": "kind",
      "canonical_label": "Third party B",
      "role_guess": "role_guess",
      "decision": "subject",
      "decision_source": "decision_source",
      "flagged": false,
      "placeholder_letter": "B",
      "mention_count": 17,
      "document_count": 312,
      "merged_into": "8c1f0b2a-0000-4000-8000-00000000e001",
      "created_at": "2026-09-04T09:12:44Z",
      "updated_at": "2026-09-04T09:12:44Z"
    }
  ],
  "next_cursor": null
}

Failures: 400 invalid_cursor, invalid_request; 401 invalid_key; 403 api_access_unavailable, insufficient_scope, not_authorized; 404 not_found; 429 rate_limited; 500 internal_error.

GEThttps://veil.pritect.ai/api/v1/cases/{caseId}/entities/{entityId}

Read one entity

Scope
entities:read
Behind it
RLS read
Idempotency key
not accepted
Response 200, Entity
{
  "id": "8c1f0b2a-0000-4000-8000-00000000c001",
  "case_id": "8c1f0b2a-0000-4000-8000-00000000c001",
  "kind": "kind",
  "canonical_label": "Third party B",
  "role_guess": "role_guess",
  "decision": "subject",
  "decision_source": "decision_source",
  "flagged": false,
  "placeholder_letter": "B",
  "mention_count": 17,
  "document_count": 312,
  "merged_into": "8c1f0b2a-0000-4000-8000-00000000e001",
  "created_at": "2026-09-04T09:12:44Z",
  "updated_at": "2026-09-04T09:12:44Z"
}

Failures: 400 invalid_request; 401 invalid_key; 403 api_access_unavailable, insufficient_scope, not_authorized; 404 not_found; 429 rate_limited; 500 internal_error.

PATCHhttps://veil.pritect.ai/api/v1/cases/{caseId}/entities/{entityId}

Confirm an entity as a third party

Scope
entities:write
Behind it
confirm_entity, or 403 per decision D3
Idempotency key
accepted
Request body, EntityDecisionRequest
{
  "decision": "third_party"
}
Response 200, Entity
{
  "id": "8c1f0b2a-0000-4000-8000-00000000c001",
  "case_id": "8c1f0b2a-0000-4000-8000-00000000c001",
  "kind": "kind",
  "canonical_label": "Third party B",
  "role_guess": "role_guess",
  "decision": "subject",
  "decision_source": "decision_source",
  "flagged": false,
  "placeholder_letter": "B",
  "mention_count": 17,
  "document_count": 312,
  "merged_into": "8c1f0b2a-0000-4000-8000-00000000e001",
  "created_at": "2026-09-04T09:12:44Z",
  "updated_at": "2026-09-04T09:12:44Z"
}

Failures: 400 invalid_request; 401 invalid_key; 403 api_access_unavailable, decision_requires_a_person, insufficient_scope, not_authorized; 404 not_found; 409 idempotency_key_reused, transition_refused; 429 rate_limited; 500 internal_error.

POSThttps://veil.pritect.ai/api/v1/cases/{caseId}/entities/merge

Merge one entity into another

Scope
entities:write
Behind it
merge_case_entities
Idempotency key
accepted
Request body, MergeEntitiesRequest
{
  "source_id": "8c1f0b2a-0000-4000-8000-00000000e002",
  "target_id": "8c1f0b2a-0000-4000-8000-00000000e001"
}
Response 200, Entity
{
  "id": "8c1f0b2a-0000-4000-8000-00000000c001",
  "case_id": "8c1f0b2a-0000-4000-8000-00000000c001",
  "kind": "kind",
  "canonical_label": "Third party B",
  "role_guess": "role_guess",
  "decision": "subject",
  "decision_source": "decision_source",
  "flagged": false,
  "placeholder_letter": "B",
  "mention_count": 17,
  "document_count": 312,
  "merged_into": "8c1f0b2a-0000-4000-8000-00000000e001",
  "created_at": "2026-09-04T09:12:44Z",
  "updated_at": "2026-09-04T09:12:44Z"
}

Failures: 400 invalid_request; 401 invalid_key; 403 api_access_unavailable, insufficient_scope, not_authorized; 404 not_found; 409 idempotency_key_reused, transition_refused; 429 rate_limited; 500 internal_error.

POSThttps://veil.pritect.ai/api/v1/cases/{caseId}/entities/{entityId}/split

Split normalized keys out into a new entity

Scope
entities:write
Behind it
split_case_entity
Idempotency key
accepted
Request body, SplitEntityRequest
{
  "normalized_keys": [
    "third-party-b"
  ]
}
Response 201, Entity
{
  "id": "8c1f0b2a-0000-4000-8000-00000000c001",
  "case_id": "8c1f0b2a-0000-4000-8000-00000000c001",
  "kind": "kind",
  "canonical_label": "Third party B",
  "role_guess": "role_guess",
  "decision": "subject",
  "decision_source": "decision_source",
  "flagged": false,
  "placeholder_letter": "B",
  "mention_count": 17,
  "document_count": 312,
  "merged_into": "8c1f0b2a-0000-4000-8000-00000000e001",
  "created_at": "2026-09-04T09:12:44Z",
  "updated_at": "2026-09-04T09:12:44Z"
}

Failures: 400 invalid_request; 401 invalid_key; 403 api_access_unavailable, insufficient_scope, not_authorized; 404 not_found; 409 idempotency_key_reused, transition_refused; 429 rate_limited; 500 internal_error.

POSThttps://veil.pritect.ai/api/v1/cases/{caseId}/review/complete

Complete review, which starts rendering

Scope
entities:write
Behind it
complete_case_review
Idempotency key
accepted
Response 200, Case
{
  "id": "8c1f0b2a-0000-4000-8000-00000000c001",
  "org_id": "8c1f0b2a-0000-4000-8000-00000000a001",
  "reference": "VC-2026-0007",
  "status": "draft",
  "jurisdiction_packs": [
    "gdpr-eu"
  ],
  "locked_at": "2026-09-04T09:12:44Z",
  "released_at": "2026-09-04T09:12:44Z",
  "purge_at": "2026-09-04T09:12:44Z",
  "created_at": "2026-09-04T09:12:44Z",
  "updated_at": "2026-09-04T09:12:44Z",
  "external_reference": "REQ-EXAMPLE-0042",
  "tombstone": {}
}

Failures: 400 invalid_request; 401 invalid_key; 403 api_access_unavailable, insufficient_scope, not_authorized; 404 not_found; 409 idempotency_key_reused, transition_refused; 429 rate_limited; 500 internal_error.

POSThttps://veil.pritect.ai/api/v1/cases/{caseId}/packaging/retry

Start packaging again after the package job gave up

Scope
cases:write
Behind it
retry_case_packaging
Idempotency key
accepted
Response 200, Case
{
  "id": "8c1f0b2a-0000-4000-8000-00000000c001",
  "org_id": "8c1f0b2a-0000-4000-8000-00000000a001",
  "reference": "VC-2026-0007",
  "status": "draft",
  "jurisdiction_packs": [
    "gdpr-eu"
  ],
  "locked_at": "2026-09-04T09:12:44Z",
  "released_at": "2026-09-04T09:12:44Z",
  "purge_at": "2026-09-04T09:12:44Z",
  "created_at": "2026-09-04T09:12:44Z",
  "updated_at": "2026-09-04T09:12:44Z",
  "external_reference": "REQ-EXAMPLE-0042",
  "tombstone": {}
}

Failures: 400 invalid_request; 401 invalid_key; 403 api_access_unavailable, insufficient_scope, not_authorized; 404 not_found; 409 case_not_ready, idempotency_key_reused, transition_refused; 429 rate_limited; 500 internal_error.

POSThttps://veil.pritect.ai/api/v1/cases/{caseId}/release

Release the case

Scope
cases:release
Behind it
release_case
Idempotency key
accepted
Response 200, Case
{
  "id": "8c1f0b2a-0000-4000-8000-00000000c001",
  "org_id": "8c1f0b2a-0000-4000-8000-00000000a001",
  "reference": "VC-2026-0007",
  "status": "draft",
  "jurisdiction_packs": [
    "gdpr-eu"
  ],
  "locked_at": "2026-09-04T09:12:44Z",
  "released_at": "2026-09-04T09:12:44Z",
  "purge_at": "2026-09-04T09:12:44Z",
  "created_at": "2026-09-04T09:12:44Z",
  "updated_at": "2026-09-04T09:12:44Z",
  "external_reference": "REQ-EXAMPLE-0042",
  "tombstone": {}
}

Failures: 400 invalid_request; 401 invalid_key; 403 api_access_unavailable, insufficient_scope, not_authorized; 404 not_found; 409 bundle_not_ready, case_not_ready, idempotency_key_reused, spot_check_miss_stalled, spot_check_miss_unresolved, spot_check_pending, transition_refused; 429 rate_limited; 500 internal_error.

POSThttps://veil.pritect.ai/api/v1/cases/{caseId}/bundle

Sign a 120 second bundle download URL

Scope
bundle:read
Behind it
record_bundle_download
Idempotency key
not accepted
Response 200, BundleUrl
{
  "url": "https://storage.example.com/object/sign/bundles/VC-2026-0007.zip",
  "expires_in_seconds": 120
}

Failures: 400 invalid_request; 401 invalid_key; 403 api_access_unavailable, insufficient_scope, not_authorized; 404 not_found; 409 case_not_released, transition_refused; 429 rate_limited; 500 internal_error.

GEThttps://veil.pritect.ai/api/v1/cases/{caseId}/quality-report

The numbers the bundle's quality report renders

Scope
cases:read
Behind it
case_quality_summary
Idempotency key
not accepted
Response 200, QualityReport
{
  "case_id": "8c1f0b2a-0000-4000-8000-00000000c001",
  "generated_at": "2026-09-04T09:12:44Z",
  "versions": {
    "detector": "1.4.0",
    "prompt": "2026-08-01",
    "model": "mistral-large"
  },
  "document_count": 312,
  "included_count": 309,
  "withheld_count": 3,
  "verifier_withheld_count": 0,
  "page_count": 1284,
  "subject_entities": 1,
  "kept_entities": 4,
  "third_party_entities": 26,
  "unknown_entities": 0,
  "residual_rounds_completed": 1,
  "health_flagged": false,
  "documents": [
    {
      "document_id": "8c1f0b2a-0000-4000-8000-00000000d001",
      "router": "router",
      "ocr_source": false,
      "chunk_total": 1,
      "chunk_failed": 1,
      "chunk_dropped": 1,
      "entity_count": 1,
      "unlocated_count": 1,
      "coverage_unit": "pages",
      "coverage_read": 1,
      "coverage_present": 1,
      "coverage_unsized_loss": false,
      "verification_passed": false,
      "verify_runs": 1,
      "verify_hits": 1,
      "verify_repairs": 1,
      "rendered_at": "2026-09-04T09:12:44Z",
      "residual_hits": 1,
      "status": "included"
    }
  ]
}

Failures: 400 invalid_request; 401 invalid_key; 403 api_access_unavailable, insufficient_scope, not_authorized; 404 not_found; 409 case_purged, report_not_ready; 429 rate_limited; 500 internal_error.

GEThttps://veil.pritect.ai/api/v1/cases/{caseId}/spot-check

List the case's spot-check pages

Scope
cases:read
Behind it
RLS read
Idempotency key
not accepted
Paging
limit and cursor
Response 200
{
  "data": [
    {
      "id": "8c1f0b2a-0000-4000-8000-00000000c001",
      "case_id": "8c1f0b2a-0000-4000-8000-00000000c001",
      "document_id": "8c1f0b2a-0000-4000-8000-00000000d001",
      "page_number": 4,
      "stratum": "stratum",
      "answer": "answer",
      "resolved_at": "2026-09-04T09:12:44Z",
      "answered_at": "2026-09-04T09:12:44Z",
      "created_at": "2026-09-04T09:12:44Z"
    }
  ],
  "next_cursor": null
}

Failures: 400 invalid_cursor, invalid_request; 401 invalid_key; 403 api_access_unavailable, insufficient_scope, not_authorized; 404 not_found; 429 rate_limited; 500 internal_error.

POSThttps://veil.pritect.ai/api/v1/cases/{caseId}/spot-check/{pageId}/clear

Refused: a person answers spot check pages in the dashboard

Scope
cases:write
Behind it
nothing, answered 403 decision_requires_a_person before anything is read
Idempotency key
accepted

Always refused with 403 decision_requires_a_person. A spot check page is answered by a person in the Veil dashboard, not over the API. To process cases through the API alone, an admin can set spot checks to None under Settings, Organization; every plan with API access allows it, and it applies to cases packaged after the change.

Failures: 400 invalid_request; 401 invalid_key; 403 api_access_unavailable, decision_requires_a_person, insufficient_scope, not_authorized; 404 not_found; 409 idempotency_key_reused, transition_refused; 429 rate_limited; 500 internal_error.

Webhook endpoints

POSThttps://veil.pritect.ai/api/v1/webhook-endpoints/{endpointId}/ping

Send a test ping through the real delivery path

Scope
webhooks:read
Behind it
ping_webhook_endpoint
Idempotency key
accepted
Response 202, WebhookDelivery
{
  "id": "8c1f0b2a-0000-4000-8000-00000000c001",
  "endpoint_id": "8c1f0b2a-0000-4000-8000-00000000f001",
  "event": "case.status_changed",
  "case_id": "8c1f0b2a-0000-4000-8000-00000000c001",
  "status": "pending",
  "attempts": 1,
  "next_attempt_at": "2026-09-04T09:12:44Z",
  "last_status_code": 200,
  "last_error_code": "last_error_code",
  "delivered_at": "2026-09-04T09:12:44Z",
  "created_at": "2026-09-04T09:12:44Z"
}

Failures: 400 invalid_request; 401 invalid_key; 403 api_access_unavailable, insufficient_scope, not_authorized; 404 not_found; 409 idempotency_key_reused, transition_refused; 429 rate_limited; 500 internal_error.

Reference / API6 events · Sheet 09 of 12 · Updated 20 Sep 2026
Webhooks

How Veil tells you a case moved

Register an endpoint in the Developer area, pick the events it should hear, and Veil posts to it. The signing secret is shown once, at registration and again on rotation, and is stored encrypted: nothing hands it back, which is why an endpoint offers rotation rather than a reveal.

Payloads carry ids, statuses, counts, timestamps and the case reference, and nothing else. No filename, no entity label, no anchor value, no document excerpt, no address, no name. The reference is included because it is Veil's own generated string and names nobody, and one human readable handle is what a customer needs to reconcile against their own ticket. The external reference you can set when you create a case is not in a payload either: it is your own string and may point at a person, so a receiver that wants it reads the case by its id.

Payloads are signed and not encrypted. TLS protects them in transit, and there is nothing in one whose disclosure to a network observer would matter, which is what makes the rule above load bearing rather than decorative.

A member of your organisation can send a delivery again from the delivery log in the Developer area, for as long as the log keeps it. That queues a new delivery with a new Veil-Delivery and a fresh signature, carrying the same payload, which is why a receiver deduplicates on the payload's id rather than on the header. Sending a delivery again is a dashboard action and has no API endpoint.

POSTcase.status_changed

Sent to every active endpoint of the organisation subscribed to case.status_changed. Verify Veil-Signature over the raw body before parsing it.

Payload, WebhookCaseEvent
{
  "id": "8c1f0b2a-0000-4000-8000-00000000f001",
  "type": "case.status_changed",
  "created_at": "2026-09-04T09:12:44Z",
  "org_id": "8c1f0b2a-0000-4000-8000-00000000a001",
  "data": {
    "case_id": "8c1f0b2a-0000-4000-8000-00000000c001",
    "reference": "VC-2026-0007",
    "status": "ready",
    "previous_status": "verifying",
    "document_count": 312,
    "included_count": 309,
    "withheld_count": 3,
    "health_flagged": false
  }
}
POSTcase.blocked_health

Sent to every active endpoint of the organisation subscribed to case.blocked_health. Verify Veil-Signature over the raw body before parsing it.

Payload, WebhookCaseEvent
{
  "id": "8c1f0b2a-0000-4000-8000-00000000f001",
  "type": "case.blocked_health",
  "created_at": "2026-09-04T09:12:44Z",
  "org_id": "8c1f0b2a-0000-4000-8000-00000000a001",
  "data": {
    "case_id": "8c1f0b2a-0000-4000-8000-00000000c001",
    "reference": "VC-2026-0007",
    "status": "blocked_health",
    "previous_status": "processing",
    "document_count": 312,
    "included_count": 309,
    "withheld_count": 3,
    "health_flagged": false
  }
}
POSTcase.ready

Sent to every active endpoint of the organisation subscribed to case.ready. Verify Veil-Signature over the raw body before parsing it.

Payload, WebhookCaseEvent
{
  "id": "8c1f0b2a-0000-4000-8000-00000000f001",
  "type": "case.ready",
  "created_at": "2026-09-04T09:12:44Z",
  "org_id": "8c1f0b2a-0000-4000-8000-00000000a001",
  "data": {
    "case_id": "8c1f0b2a-0000-4000-8000-00000000c001",
    "reference": "VC-2026-0007",
    "status": "ready",
    "previous_status": "verifying",
    "document_count": 312,
    "included_count": 309,
    "withheld_count": 3,
    "health_flagged": false
  }
}
POSTcase.released

Sent to every active endpoint of the organisation subscribed to case.released. Verify Veil-Signature over the raw body before parsing it.

Payload, WebhookCaseEvent
{
  "id": "8c1f0b2a-0000-4000-8000-00000000f001",
  "type": "case.released",
  "created_at": "2026-09-04T09:12:44Z",
  "org_id": "8c1f0b2a-0000-4000-8000-00000000a001",
  "data": {
    "case_id": "8c1f0b2a-0000-4000-8000-00000000c001",
    "reference": "VC-2026-0007",
    "status": "released",
    "previous_status": "ready",
    "document_count": 312,
    "included_count": 309,
    "withheld_count": 3,
    "health_flagged": false
  }
}
POSTcase.purged

Sent to every active endpoint of the organisation subscribed to case.purged. Verify Veil-Signature over the raw body before parsing it.

Payload, WebhookCaseEvent
{
  "id": "8c1f0b2a-0000-4000-8000-00000000f001",
  "type": "case.purged",
  "created_at": "2026-09-04T09:12:44Z",
  "org_id": "8c1f0b2a-0000-4000-8000-00000000a001",
  "data": {
    "case_id": "8c1f0b2a-0000-4000-8000-00000000c001",
    "reference": "VC-2026-0007",
    "status": "purged",
    "previous_status": "released",
    "document_count": 312,
    "included_count": 309,
    "withheld_count": 3,
    "health_flagged": false
  }
}
POSTwebhook.ping

Sent to every active endpoint of the organisation subscribed to webhook.ping. Verify Veil-Signature over the raw body before parsing it.

Payload, WebhookPingEvent
{
  "id": "8c1f0b2a-0000-4000-8000-00000000f001",
  "type": "webhook.ping",
  "created_at": "2026-09-04T09:12:44Z",
  "org_id": "8c1f0b2a-0000-4000-8000-00000000a001",
  "data": {
    "endpoint_id": "8c1f0b2a-0000-4000-8000-00000000f001",
    "sent_at": "2026-09-04T09:12:44Z"
  }
}
Reference / APISheet 10 of 12 · Updated 20 Sep 2026
Verifying a signature

Check the raw body before you parse it

Every delivery carries Veil-Signature alongside Veil-Delivery and Veil-Event. The algorithm is HMAC-SHA256 over <t>.<raw request body>, keyed with the endpoint's signing secret.

Veil-Signature
t=<unix seconds>,v1=<hex>
  1. Read the raw request body as text before parsing it. A JSON round trip reorders keys and changes whitespace, and either would change the bytes the signature was taken over.
  2. Split the Veil-Signature header on commas into t and v1.
  3. Recompute HMAC-SHA256 over the exact string t + "." + body, keyed with your endpoint's signing secret, and compare the hex digest with v1 in constant time.
  4. Refuse a timestamp more than 300 seconds old, which is what makes a captured delivery useless as a replay.
  5. Deduplicate on the payload's id. Veil-Delivery stays the same across the retries of one delivery, but a delivery sent again from the delivery log carries a new Veil-Delivery and the same payload, so only the payload's id recognises the event you already handled. Delivery is at least once; it is not exactly once.

Signature verification is the part of a webhook integration that is easy to get subtly wrong, and getting it wrong fails open. Veil publishes a verifier alongside the contract so that the check is a call rather than a transcription of the recipe below. The same package carries the request and response types, the closed error vocabulary and the scope list, so a client branches on constants rather than on strings it typed. The verifier is verifyWebhookSignature, exported from @pritect-veil/api.

Reference / APISheet 11 of 12 · Updated 20 Sep 2026
Delivery and retries

At least once, and never exactly once

Deliveries are dispatched by a scheduled job rather than at the instant of the transition, so a delivery waits up to one minute plus the dispatch itself. Do not build a synchronous flow on top of it.

A delivery is attempted up to 6 times. The first goes on the next dispatch tick, then Veil waits 1 minute, then 5 minutes, then 25 minutes, then 2 hours, then 6 hours. A 4xx other than 408 and 429 is terminal on the first response. 408, 429, every 5xx, a timeout at 10 seconds and a connection failure are retried.

After 20 consecutive failures an endpoint is disabled and somebody has to enable it again, because an endpoint nobody is listening to should stop generating traffic and start generating a message. Redirects are not followed. The delivery log holds 30 days.

A case moves to ready when verification completes, the last stage of the pipeline, and packaging starts after that: the bundle is written next, and the spot check pages are sampled immediately after the bundle. So a consumer that reacts to case.ready and presses release in the same breath is refused bundle_not_ready until the bundle exists, which takes seconds for a small case and minutes for a large one, and a consumer that reads the sample in that window can legitimately get an empty list. Poll the spot check until pages appear, or attempt the release and treat bundle_not_ready and spot_check_pending as the two signals that packaging is still landing and a page is still waiting to be answered. Do not read one empty list and conclude the case was sampled and had nothing to check.

Dispatch latency
up to 1 minute
Attempts
6
Replay window
5 minutes
Reference / API6 boundaries · Sheet 12 of 12 · Updated 20 Sep 2026
Scope

What version one deliberately does not do

A boundary stated is a boundary somebody can build around. These are deliberate, and each one has a reason that is not a roadmap entry.

Two decisions a person makes
Marking an entity as the data subject and keeping one as non personal both disclose personal data into the released bundle, which is the judgement what a disclosure keeps and what comes out is about. Both are made in the dashboard behind a gate that has to be typed out, and the API answers a request to make either of them with a refusal rather than a quiet success.
Anything that carries document text
The dashboard can show the sentence an entity was found in. The API cannot, because every field that carries document text is a field a customer's log aggregator may end up holding. The three decisions version one does allow need none of it.
Answering a spot check
A spot check exists so that a person looks at sampled pages of the redacted output before release, so clearing a page over the API is refused with the same answer as the two decisions above. Reporting a miss stays in the dashboard too: a miss carries a leaked value somebody typed, and there that value is encrypted under the case key a purge destroys before it touches a row. The API can read a spot check. To process cases through the API alone, an admin sets spot checks to None under Settings, Organization; every plan with API access allows it, and it applies to cases packaged after the change.
Resuming a case stopped on quality
The API can withhold a failed document and retry packaging, which are the levers a stopped case usually needs. Resuming a case stopped at blocked_health afterwards, and sending documents the verifier withheld back through render and verify, are an admin's acts in the dashboard.
Connector pulls and the settings tree
Starting a pull from a connected workspace needs an interactive consent that already exists in the dashboard. There is no organisation, member, invitation, billing or subscription endpoint either: the API drives cases, not the tenancy. Releasing a case through this API meters it exactly as releasing it from the dashboard does, at the rate the published plans state.
A console on this page
There is no button here that sends a request. A console needs a live key in a browser, which is the one place a key should never be, and building one honestly needs a test mode that does not exist yet.

This page is generated from the contract itself. Every path, scope, error code, payload and interval on it is read from the document Veil serves, so it describes the API that is running rather than the one that was written about.