Skip to content
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.

Base URLhttps://veil.pritect.ai/api/v1

Version1.0.0

OpenAPI3.1.0

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 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
Scopes

Nine of them, and they only ever narrow

A request outside the key's scopes is refused with a fixed code, before anything is read or 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.

  • cases:read
  • cases:write
  • cases:release
  • documents:read
  • documents:write
  • entities:read
  • entities:write
  • bundle:read
  • webhooks:read
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.
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.
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.
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.
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"
}
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
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.

Endpoints

All 25 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
{}
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"
}

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"
    }
  ],
  "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",
  "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

Scope
cases:write
Behind it
delete_case
Idempotency key
not accepted

Answers 204 with no body. Delete 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}/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}/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"
}

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"
}

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}/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"
}

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}/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,
      "verification_passed": false,
      "residual_hits": 1,
      "status": "included"
    }
  ]
}

Failures: 400 invalid_request; 401 invalid_key; 403 api_access_unavailable, insufficient_scope, not_authorized; 404 not_found; 409 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

Answer a spot-check page as clear

Scope
cases:write
Behind it
answer_spot_check_clear
Idempotency key
accepted
Response 200, SpotCheckPage
{
  "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"
}

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.

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.

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.

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.

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"
  }
}
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. 01Read 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. 02Split the Veil-Signature header on commas into t and v1.
  3. 03Recompute 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. 04Refuse a timestamp more than 300 seconds old, which is what makes a captured delivery useless as a replay.
  5. 05Deduplicate on Veil-Delivery, which is stable across retries. 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.

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.

Dispatch latency
up to 1 minute
Attempts
6
Replay window
5 minutes
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. 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.
Reporting a spot check miss
A miss carries a leaked value somebody typed. It stays in the dashboard, where that value is encrypted under the case key before it touches a row. The API can read a spot check and clear a page.
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.
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.