> ## Documentation Index
> Fetch the complete documentation index at: https://docs.privue.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Try a staged workflow

> Run a staged verification end to end in a notebook, using documents you already have.

For trying the API. Every block below is a notebook cell: open a Jupyter notebook, save a few real
documents beside it, and run the cells in order. Nothing here is an integration pattern, just the
shortest path to seeing a real record.

You need a workflow whose `mode` is `staged`. `GET /workflows` lists yours with the mode of each,
along with how many credits each has left.

Reach for your `uat` key, whose token begins `uat_`, so the run below comes out of a `uat` workflow's
credits rather than out of what you bought for real work. Whichever key you use, the keys below are
placeholders for your own, and a `uat` workflow's key ends `-uat`.

The verification is real either way. There is no sandbox: your documents really are read, and really
are confirmed with the bodies that issued them.

```python theme={"system"}
!pip install httpx
```

```python theme={"system"}
import os
import time

import httpx

os.environ["PRIVUE_API_KEY"] = "your key"

privue = httpx.Client(
    base_url="https://api.verify.privue.ai",
    headers={"Authorization": f"Bearer {os.environ['PRIVUE_API_KEY']}"},
    timeout=60,
)
```

<Note>
  Already keep the key in your environment? Drop the assignment and the client
  picks it up from the kernel. Nothing but the client reads it, so a plain
  `api_key = "your key"` variable does the job just as well.
</Note>

Every error comes back as `{"detail": "..."}`. A cell that fails raises on the response it already
kept in `response`, so `response.json()["detail"]` in a new cell says what went wrong.

## 1. See what the workflow asks for

Before uploading anything, read the workflow. Its `mode` says whether you run it, and each step's
`intake` says exactly how it is answered: the slots its documents go in, the kinds of document each
slot takes, with how many files each kind arrives as and in what formats, and the values it takes in
the run request. A kind's `label` is your own name for that document where your workflow gives it one,
and `null` where it does not; the wording the person providing it reads belongs to their screens, not
to this API.

```python theme={"system"}
response = privue.get("/workflows/vendor-verification")
response.raise_for_status()
workflow = response.json()

print(f"{workflow['key']} is {workflow['mode']}")

for step in workflow["steps"]:
    need = "required" if step["required"] else "optional"
    print(f"{step['key']:14} {need}")
    for slot in step["intake"]["slots"]:
        need_slot = "required" if slot["required"] else "optional"
        print(f"               slot  {slot['key']:18} {need_slot}")
        for kind in slot["kinds"]:
            accepts = ", ".join(f"{a['min']}-{a['max']} {'/'.join(a['types'])}" for a in kind["accept"])
            print(f"                     {kind['key']:18} {str(kind['label']):20} {accepts}")
    for value in step["intake"]["values"]:
        print(f"               value {value}")
```

```text theme={"system"}
vendor-verification is staged
gst            optional
               slot  gst-certificate    required
                     gst-certificate    None                 1-1 pdf, 3-3 image
pan            required
               slot  pan-card           required
                     pan-card           None                 1-1 image/pdf
bank           optional
               slot  bank-account-proof required
                     statement          None                 1-1 pdf, 1-6 image
                     cheque             None                 1-1 image/pdf
                     letter             None                 1-1 image/pdf
               value account_number
               value ifsc
udyam          optional
               slot  udyam-certificate  required
                     udyam-certificate  None                 1-1 image/pdf
cross-check    required
```

`required` is what decides whether you can leave a document out, and step 8 depends on it. The
cross-check lists no slots and no values: it is answered from what the earlier steps establish, so you
send nothing for it.

<Note>
  A workflow whose `mode` is `hosted` is walked by the person being verified,
  not run by you. Uploading to one, or running its checks, is refused with
  `409`. See [the other recipe](/verification/recipes/hosted).
</Note>

## 2. Create a verification

`reference_user_id` is your own identifier for the subject. On a staged workflow it is also what
makes creating idempotent, and the handle that survives once the verification's data has been deleted,
so make it something you can reconcile against your own records. There is no user to sign in, so
there is no mobile number to give.

```python theme={"system"}
response = privue.post(
    "/verifications",
    json={"workflow_key": "vendor-verification", "reference_user_id": "VENDOR-4471"},
)
response.raise_for_status()
verification_id = response.json()["id"]
print(verification_id)
```

A `402` here means the workflow has no credits left, and a `409` that it already has as many
verifications going as it may. Read the workflow to see which: `credits.remaining` and `runs`. Neither
clears by retrying. See [Credits and limits](/verification/credits).

The credit is used when the verification ends, not here, so the record you just created reads
`credits_consumed: 0` until step 8 completes it.

## 3. Upload the documents

One call per file, naming the step and slot from step 1, with each path relative to the notebook.

```python theme={"system"}
documents = [
    ("gst", "gst-certificate", "gst-certificate.pdf"),
    ("pan", "pan-card", "pan-card.jpg"),
]
uploaded = {}

for step_key, slot, path in documents:
    with open(path, "rb") as handle:
        response = privue.post(
            f"/verifications/{verification_id}/documents",
            data={"step_key": step_key, "slot": slot},
            files={"file": (path, handle)},
        )
    response.raise_for_status()
    stored = response.json()
    uploaded[path] = stored["id"]
    print(f"{step_key:6} {stored['id']} {stored['filename']:24} {stored['size_bytes']:>9,} bytes")
```

Send several files to the same slot where it takes several. Nothing is read from a document as it
arrives, so upload everything before the next step.

Run the cell twice and the second upload is refused with `422` wherever the slot already holds as many
files as it takes. Withdraw the file below, or reset as in step 7.

The bank step is left out on purpose: it takes typed details or a document, and the run request below
sends the details. To answer it with a cancelled cheque instead, upload one to its
`bank-account-proof` slot with `"kind": "cheque"`, since that slot names more than one kind of proof,
and send no `bank` entry in step 4.

<Warning>
  A file the step cannot use is refused on its own call: `415` for a format the
  slot does not take, `413` for one over the size limit, which is 20 MB for a
  PDF and 10 MB for an image. Nothing else you uploaded is affected.
</Warning>

Uploaded the wrong file? Withdraw it by its id, which the cell above kept, and the slot has room again.

```python theme={"system"}
document_id = uploaded["pan-card.jpg"]

response = privue.delete(f"/verifications/{verification_id}/documents/{document_id}")
response.raise_for_status()
```

## 4. Run the checks

One request says the submission is complete. Steps whose `intake.values` names values take them here;
every other step is answered from its documents.

```python theme={"system"}
response = privue.post(
    f"/verifications/{verification_id}/run",
    json={"steps": {"bank": {"account_number": "50100234567890", "ifsc": "HDFC0001234"}}},
)
response.raise_for_status()
print(response.json()["run_requested_at"])
```

Had you uploaded a cancelled cheque or bank statement to the `bank` step instead, you would drop the
`bank` entry and send `{"steps": {}}`: the document answers the step, and sending both is refused.

This returns as soon as the request is accepted, with `run_requested_at` set. From that moment the
submission is frozen: uploads and withdrawals are refused until you reset. The checks run after it.

One request answers the whole submission; there is no call that runs a single step.

## 5. Watch it settle

Poll until the verification leaves `open`. The checks are document reads and calls to the issuing
registries, so give it a minute.

```python theme={"system"}
for _ in range(60):
    response = privue.get(f"/verifications/{verification_id}")
    response.raise_for_status()
    record = response.json()
    print(record["status"], record["progress"])
    if record["status"] != "open":
        break
    time.sleep(5)
```

The cell holds the kernel while it polls, and stops after five minutes rather than running for ever.
Still `open` when it stops? Run it again: reading a verification changes nothing, and the last `record`
it read is what the cells below use.

## 6. Read what it found

Every step reports a state and, where it did not pass cleanly, the reasons why.

```python theme={"system"}
for step in record["steps"]:
    notes = [note["message"] for note in step["reasons"] + step["gaps"]]
    print(f"{step['key']:22} {step['state']:14} {' | '.join(notes)}")
```

```text A run worth a second look theme={"system"}
gst                    passed
pan                    passed
bank                   passed
udyam                  not-supplied
cross-check            passed         Bank account holder reads AVESCO HOSPITEX, which is close but not an exact match (65% match).
```

A step that `passed` with a **gap** is the case worth routing to a person: every document is real and
confirmed at source, but something is not an exact match. A step that `failed` carries the **reasons**
it did not stand up. A step in `error` was never checked, because a source could not be reached.

Cross-check scores are on the step's outputs, if you want the numbers rather than the sentences:

```python theme={"system"}
for step in record["steps"]:
    if step["type"] == "name-match" and step["collected"]:
        for match in step["collected"]["outputs"]["matches"]:
            print(f"{match['check']:22} {match['score']:>3}%  matched={match['matched']}")
```

## 7. Go round again

Reset clears every answer, every file and the run request, and reopens the verification, so you can
try different documents without creating a new one.

```python theme={"system"}
response = privue.post(f"/verifications/{verification_id}/reset")
response.raise_for_status()
```

Creating is idempotent per reference and workflow, so calling step 2 again returns this same
verification rather than a fresh one, answering `200` instead of `201` to tell you so. Reset is how you
get a clean run.

To work out which of several documents is the problem, reset and send fewer. Send at least two, though:
a cross-check scores names against one another, so on a round with one document it has nothing to
compare and drops out of the record.

Each round here clears a run nothing was charged for, so it draws one of the workflow's resets. Watch
`resets.remaining` while you iterate, and see
[When the resets run out](/verification/credits#when-the-resets-run-out).

## 8. See what a bad submission does

Reset once more so nothing is uploaded, and run the checks on that.

```python theme={"system"}
response = privue.post(f"/verifications/{verification_id}/reset")
response.raise_for_status()

response = privue.post(f"/verifications/{verification_id}/run", json={"steps": {}})
print(response.status_code, response.json().get("detail", "accepted"))
```

What comes back depends on your workflow. Where a document is **required**, the run is refused with
`422` before anything runs, naming every step that has nothing - so nothing was charged for and nothing
was checked:

```text theme={"system"}
422 bank: nothing was supplied for this step; pan: nothing was supplied for this step
```

Where **every** document is optional, an empty submission is a valid one: the run is accepted and
settles with each step `not-supplied` and nothing checked. It reads `completed` with no failures, which
is not the same as a business that passed, so read `progress` and the step states rather than the status
alone.

The `422` also covers a slot you filled only part of, which reads `gst: slot 'gst-certificate' needs 2
more file(s)`, and a step given both values and a document. Correct the submission and ask again.

<Note>
  Run the checks on a verification that has already settled and you get `409`
  rather than `422`: it is no longer open, so there is nothing to submit to.
  Reset it first, as above.
</Note>

## 9. Purge it

When you are done, delete everything you supplied. The record stays readable, saying what each step
concluded and that you asked for the purge, and holds nothing else.

```python theme={"system"}
response = privue.post(f"/verifications/{verification_id}/purge")
response.raise_for_status()
purged = response.json()
print(purged["status"], purged["expired_by"])  # expired client
```

## The record you just read

The finished record behind the run above, from the `vendor-verification` workflow. Yours differs:
which steps a verification runs comes from the workflow configured for your integration. The envelope
around them is the same for everyone.

<Accordion title="GET /verifications/{verification_id}">
  ```json theme={"system"}
  {
    "id": "6a2f0c91-3b7e-4d15-8c4a-0f9e2b7d1c58",
    "mode": "staged",
    "status": "completed",
    "workflow_key": "vendor-verification",
    "version": 1,
    "mobile": null,
    "reference_user_id": "VENDOR-4471",
    "context": {},
    "return_url": null,
    "journey_url": null,
    "created_at": "2026-08-27T11:02:09",
    "run_requested_at": "2026-08-27T11:06:40",
    "submitted_at": "2026-08-27T11:08:14",
    "completed_at": "2026-08-27T11:08:16",
    "cancelled_at": null,
    "gated_at": null,
    "gated_by_step": null,
    "failed_at": null,
    "expired_at": null,
    "expired_by": null,
    "last_activity_at": "2026-08-27T11:08:14",
    "expires_after_days": 30,
    "acknowledgement": null,
    "callback": {
      "status": "delivered",
      "settled_at": "2026-08-27T11:08:16",
      "reason": null
    },
    "credits_consumed": 1,
    "resets": 0,
    "progress": {
      "total": 5,
      "passed": 4,
      "failed": 0,
      "awaiting": 0,
      "enriching": 0,
      "error": 0,
      "declined": 0,
      "not_supplied": 1,
      "undetermined": 0,
      "not_applicable": 0
    },
    "steps": [
      {
        "key": "gst",
        "title": "GST certificate",
        "label": null,
        "type": "gst-certificate",
        "required": false,
        "state": "passed",
        "reasons": [],
        "gaps": [],
        "collected": {
          "answer": {},
          "outputs": {
            "gstin": "27AAECA9012P1ZK",
            "legal_name": "AVESCO HOSPITEX PRIVATE LIMITED",
            "trade_name": "Avesco Hospitex",
            "registration_status": "Active",
            "registration_date": "2019-07-02",
            "business_constitution": "Private Limited Company",
            "taxpayer_type": "Regular",
            "e_invoice_mandated": true,
            "aggregate_turnover": "Slab: Rs. 5 Cr. to 25 Cr.",
            "authorized_signatories": [
              "Anil Deshpande",
              "Sunita Deshpande"
            ],
            "nature_of_business_activities": [
              "Wholesale Business",
              "Supplier of Services"
            ],
            "business_details": {
              "goods_details": [
                {
                  "goods_description": "Hospital furniture",
                  "hsn_code": "94029090"
                }
              ],
              "service_details": [
                {
                  "service_description": "Installation services of other goods",
                  "sac_code": "995479"
                }
              ]
            },
            "filings": [
              {
                "return_type": "GSTR1",
                "financial_year": "2026-2027",
                "tax_period": "July",
                "status": "Filed",
                "filing_date": "2026-08-09",
                "mode_of_filing": "ONLINE"
              },
              {
                "return_type": "GSTR3B",
                "financial_year": "2026-2027",
                "tax_period": "July",
                "status": "Filed",
                "filing_date": "2026-08-19",
                "mode_of_filing": "ONLINE"
              },
              {
                "return_type": "GSTR9",
                "financial_year": "2024-2025",
                "tax_period": "Annual",
                "status": "Filed",
                "filing_date": "2025-12-18",
                "mode_of_filing": "ONLINE"
              }
            ],
            "business_pan": "AAECA9012P",
            "sole_proprietor_pan": null,
            "addresses": [
              {
                "address": "Plot 14, MIDC Bhosari, Pune, Maharashtra 411026",
                "tag": "Principal Business Address"
              }
            ],
            "primary_business_address": {
              "street": "Plot 14, MIDC Bhosari",
              "city": "Pune",
              "state": "Maharashtra",
              "country": "India",
              "postal_code": "411026"
            },
            "other_business_address": null,
            "names": [
              {
                "name": "AVESCO HOSPITEX PRIVATE LIMITED",
                "tag": "Legal Name"
              },
              {
                "name": "Avesco Hospitex",
                "tag": "Trade Name"
              }
            ],
            "promoters": [
              "Anil Deshpande",
              "Sunita Deshpande"
            ],
            "business_email": "accounts@avescohospitex.in",
            "business_mobile": "9822045678"
          }
        },
        "documents": [
          {
            "id": "d1e4a7b2-5c38-4f9e-8a61-2b7c0d3e9f14",
            "slot": "gst-certificate",
            "kind": "gst-certificate",
            "label": null,
            "filename": "gst-certificate.pdf",
            "content_type": "application/pdf",
            "size_bytes": 203118,
            "sha256": "1c8f4e2a9b7d63f05e1a4c8b2d7f9e3a6b5c0d1e2f3a4b5c6d7e8f9a0b1c2d3e",
            "received_at": "2026-08-27T11:04:52"
          }
        ]
      },
      {
        "key": "pan",
        "title": "PAN card",
        "label": null,
        "type": "pan-card",
        "required": true,
        "state": "passed",
        "reasons": [],
        "gaps": [],
        "collected": {
          "answer": {},
          "outputs": {
            "pan": "AAECA9012P",
            "name": "AVESCO HOSPITEX PRIVATE LIMITED",
            "date_of_birth_or_incorporation": "2019-05-21",
            "card_document_id": "3b9c6e1f-8a24-4d57-b0e3-7f1a5c8d2e69",
            "status": "valid",
            "aadhaar_seeded": null,
            "names": [
              {
                "name": "AVESCO HOSPITEX PRIVATE LIMITED",
                "tag": "PAN Holder"
              }
            ]
          }
        },
        "documents": [
          {
            "id": "3b9c6e1f-8a24-4d57-b0e3-7f1a5c8d2e69",
            "slot": "pan-card",
            "kind": "pan-card",
            "label": null,
            "filename": "pan-card.jpg",
            "content_type": "image/jpeg",
            "size_bytes": 87422,
            "sha256": "7a2d9c4e1f8b3a6d0e5c2b9f4a7d1e8c3b6a9d2f5e8c1b4a7d0e3f6c9b2a5d8e",
            "received_at": "2026-08-27T11:05:17"
          }
        ]
      },
      {
        "key": "bank",
        "title": "Bank account",
        "label": null,
        "type": "bank-account",
        "required": false,
        "state": "passed",
        "reasons": [],
        "gaps": [],
        "collected": {
          "answer": {
            "source": "typed",
            "account_number": "50100234567890",
            "ifsc": "HDFC0001234"
          },
          "outputs": {
            "source": "typed",
            "account_number": "50100234567890",
            "ifsc": "HDFC0001234",
            "registered_name": "AVESCO HOSPITEX",
            "bank_name": null,
            "branch": null,
            "names": [
              {
                "name": "AVESCO HOSPITEX",
                "tag": "Bank Account Holder"
              }
            ]
          }
        },
        "documents": []
      },
      {
        "key": "udyam",
        "title": "Udyam certificate",
        "label": null,
        "type": "udyam",
        "required": false,
        "state": "not-supplied",
        "reasons": [],
        "gaps": [],
        "collected": null,
        "documents": []
      },
      {
        "key": "cross-check",
        "title": "Name cross-check",
        "label": null,
        "type": "name-match",
        "required": true,
        "state": "passed",
        "reasons": [],
        "gaps": [
          {
            "code": "name-match-inexact",
            "message": "Bank account holder reads AVESCO HOSPITEX, which is close but not an exact match (65% match).",
            "details": {
              "check": "Bank account holder",
              "name": "AVESCO HOSPITEX",
              "score": 65
            }
          }
        ],
        "collected": {
          "answer": {},
          "outputs": {
            "reference": [
              {
                "name": "AVESCO HOSPITEX PRIVATE LIMITED",
                "tag": "Legal Name"
              }
            ],
            "matches": [
              {
                "check": "Bank account holder",
                "name": "AVESCO HOSPITEX",
                "matched_to_tag": "Legal Name",
                "score": 65,
                "matched": false
              }
            ]
          }
        },
        "documents": []
      }
    ]
  }
  ```
</Accordion>

* **Nobody signs in, so `mobile`, `return_url` and `journey_url` are null**, and `run_requested_at`
  records the moment you asked for the checks.
* **`udyam` is `not-supplied`.** Nothing was uploaded to it and it is optional, so the run closed over
  it. `collected` is `null`, no source was called, and it is not a verdict about the business - only
  the absence of a check.
* **The cross-check passed with a gap.** The bank returned `AVESCO HOSPITEX` where the business is
  registered as `AVESCO HOSPITEX PRIVATE LIMITED`: close enough not to be a different business, not
  close enough to call an exact match. `outputs.reference` names which name it scored against and
  `outputs.matches` carries the score behind every check, so you can route on the number rather than
  the sentence.
* **Take the bank details from `outputs`.** `answer` holds what you sent; `outputs` holds what the
  bank confirmed, and `registered_name` is the bank's own spelling.

[Reading the result](/verification/results) is the field-by-field reference for a step.

<Info>
  When you move from trying it to building against it, read [Staged](/verification/staged) for what is decided for you and
  why, [Callbacks](/verification/callbacks), which is what you use instead of
  polling, and [Going live](/verification/going-live) before your first real
  subject.
</Info>


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.