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

# How to: Integrate an External System

> Example workflow for processing a submission via the **Ping.Vision** API

This page describes an end-to-end clearance workflow using the **Ping.Vision** API. Code examples are available in Python and cURL.
The complete Python script is available for download at the bottom of the page.

Reach for this workflow when one of the following is true:

* You're integrating an existing underwriting or clearance system with Ping.Vision end-to-end, not just pushing submissions in.
* You need Ping to clear submissions against appetite and underwriting rules, and your system needs to react to the resulting status transitions.
* You want preliminary Ping.Maps and data analytics available before the submission is accepted.
* You need final data correction and enrichment to run after acceptance and feed back into your system.

For a simpler "submit a file and download the outputs" use case without clearance or status-driven branching, use [Send and Track SOV Submission](/workflows/ping-vision/send-and-track-submission) instead.

This is a complete, working example demonstrating how an existing system can be integrated with Ping to use Ping for:

1. Submission
2. Preliminary Ping.Maps and data analytics
3. Clearance against appetite and underwriting rules
4. Acceptance, triggering final data correction and enrichment

<Info>
  **Understanding the Workflow Architecture**

  Each step is labeled as:

  * <span style={{ color: "#0E85E0", fontWeight: "bold" }}>USER ACTION</span>:
    Operations initiated by a broker, underwriter, or clearance team
  * <span style={{ color: "#06A77C", fontWeight: "bold" }}>PING</span>: Operations
    handled by Ping's team or automated backend processes

  **Note**: In production, event polling (Step 3) runs as a background process watching **all** submissions for your team/division.
  This demo filters to a single submission for clarity.
</Info>

### 0. Preamble

Import necessary libraries, set up authentication, and define helper functions for API calls and console output formatting.

```python clear_and_triage.py lines icon='python' theme={null}
import argparse
import os
import threading
import time
from datetime import datetime, timezone
from pathlib import Path

import requests

# How often to poll for status changes (in seconds)
POLL_INTERVAL = 15

# Shared state dictionary for tracking submission statuses across threads
# Maps pingid -> current status UUID
db: dict[str, str] = {}

def color_text(text: str, color: str) -> str:
    """
    Wrap *text* in ANSI escape codes so it renders in the given *color* when
    printed to a terminal.

    Supported colors: "red", "yellow", "green". If an unsupported color is
    passed the text is returned unmodified.
    """
    colors = {"red": "\033[31m", "yellow": "\033[33m", "green": "\033[32m"}
    if color not in colors:
        return text
    reset = "\033[0m"
    return f"{colors[color]}{text}{reset}"

def authenticate(api_url: str, auth_token: str) -> dict:
    """
    Authenticate to the Ping.Vision API and return the necessary headers and base URL.
    """
    if auth_token is None:
        auth_token = os.environ.get("PINGVISION_AUTH_TOKEN_LOCAL")
    if not auth_token:
        raise RuntimeError("No auth token provided. Set PINGVISION_AUTH_TOKEN_LOCAL or pass --auth-token")

    base_url = api_url.rstrip("/")
    if not base_url.endswith("/api/v1"):
        base_url = f"{base_url}/api/v1"

    headers = {"Authorization": f"Token {auth_token}"}
    return headers, base_url
```

### 1. List User Teams

Retrieve your team configuration including `team_uuid`, `division_uuid`, and workflow `statuses`.
Use [List User Teams](/ping-vision/user-memberships/list-user-teams) to get team info and
[List Submission Statuses](/ping-vision/miscellaneous/list-submission-statuses) to map status names to UUIDs.

**Example code:**

<CodeGroup dropdown>
  ```python clear_and_triage.py lines icon='python' theme={null}
  def get_team(base_url: str, headers: dict, company_name: str, team_name: str) -> dict:
      """
      Look up a Ping.Vision team by its company and team display names.

      This calls the Ping.Vision list_teams endpoint and searches for an exact
      match on both company_name and team_name. Use it early in your workflow
      to obtain the team_uuid and division_uuid you'll need for submission
      creation and status lookups.

      If the team is not found, all available teams are printed to the console
      to help you identify the correct names, and a RuntimeError is raised.
      """
      list_teams_url = f"{base_url}/user/teams/"
      response = requests.get(list_teams_url, headers=headers)
      if response.status_code not in (200, 201):
          raise RuntimeError(f"Failed to list teams: {response.status_code}")

      teams = response.json()
      for team in teams:
          if team["company_name"] == company_name and team["team_name"] == team_name:
              return team

      print("Available teams:")
      for team in teams:
          print(f"  * {team['company_name']} / {team['team_name']} ({team['team_uuid']})")
      raise RuntimeError(f"Team not found: {company_name} / {team_name}")

  def get_statuses(base_url: str, headers: dict, division_uuid: str) -> dict[str, str]:
  """
  Retrieve the workflow statuses configured for a division and return as a name->uuid mapping.

      This calls the list_submission_statuses endpoint to get all available workflow
      statuses (e.g., "Received", "Pending Clearance", "Cleared", "Data Entry", etc.).

      Returns a dict mapping status names to their UUIDs for easy lookup when
      transitioning submissions between stages.
      """
      statuses_url = f"{base_url}/submission-status"
      params = {"division": division_uuid}
      response = requests.get(statuses_url, params=params, headers=headers)
      if response.status_code not in (200, 201):
          raise RuntimeError(f"Failed to list statuses: {response.status_code}")

      statuses = response.json()
      return {s["name"]: s["uuid"] for s in statuses}

  ```

  ```shell cURL output icon='square-terminal' theme={null}
  curl --request GET \
      --url "https://vision.pingintel.com/api/v1/user/teams/" \
      --header "Authorization: Token <your_api_key_here>"
  ```
</CodeGroup>

**Example response** *(truncated)*:

<CodeGroup dropdown>
  ```json JSON output lines icon='json' theme={null}
  [
    {
      "division_uuid": "12345678-abc1-cde2-efg3-123456789abcd",
      "team_uuid": "9876543-abc1-cde2-efg3-123456789abcd",
      "team_name": "Ping Intel",
      "statuses": [
        { "name": "Received", "uuid": "019c6d26-4468-7b89-8328-d840dc9b5e3b", "...":  "..." },
        { "name": "Cleared", "uuid": "019c6d26-4e1b-7c89-ab12-d840dc9b5e3c", "...":  "..." },
        { "name": "Data Entry", "uuid": "019c6d26-4e21-784d-bd26-9d7d70991d91", "...":  "..." },
        { "name": "Underwriting", "uuid": "019c6d26-4e24-73d5-a2f0-ac50f422ba51", "...":  "..." },
        "..."
      ],
      "...":  "..."
    }
  ]
  ```
</CodeGroup>

### 2. Create Submission

<span style={{ color: "#0E85E0", fontWeight: "bold" }}>USER ACTION</span>

Upload a submission (e.g., email with SOV attachment) using [Initiate New Submission](/ping-vision/create-submission/initiate-new-submission).
This kicks off the automated intake and clearance workflow.

**Example code:**

<CodeGroup dropdown>
  ```python clear_and_triage.py lines icon='python' theme={null}
  def create_submission(
      base_url: str,
      headers: dict,
      team_uuid: str,
      file_path: str,
      insured_name: str = "Acme Corp",
      client_ref: str = None,
  ) -> str:
      """
      Upload files to create a new submission.
      Returns the pingid of the created submission.
      """
      with open(file_path, "rb") as eml_file:
          files = [("files", (Path(file_path).name, eml_file))]
          payload = {
              "team_uuid": team_uuid,
              "insured_name": insured_name,
          }
          if client_ref:
              payload["client_ref"] = client_ref
          create_submission_url = f"{base_url}/submission"
          response = requests.post(create_submission_url, data=payload, files=files, headers=headers)
      if response.status_code not in (200, 201):
          raise RuntimeError(f"Failed to create submission: {response.status_code}")

      return response.json()["id"]

  ```

  ```shell cURL output icon='square-terminal' theme={null}
  curl --request POST \
      --url "https://vision.pingintel.com/api/v1/submission" \
      --header "Authorization: Token <your_api_key_here>" \
      --header "Content-Type: multipart/form-data" \
      --form "files=@demo_email.eml" \
      --form "team_uuid=<uuid_from_previous_step>" \
      --form "client_ref=my_salesforce_id" \
      --form "insured_name=Acme Corp"
  ```
</CodeGroup>

**Example response:**

<CodeGroup dropdown>
  ```json JSON output lines icon='json' theme={null}
  {
    "id": "p-mk-tempo-esw5ab",
    "message": "OK",
    "url": "http://vision.pingintel.com/submission/i/p-mk-tempo-esw5ab"
  }
  ```
</CodeGroup>

### 3. Poll for Status Changes via List Submission Events

<span style={{ color: "#0E85E0", fontWeight: "bold" }}>USER ACTION</span>

Poll [List Submission Events](/ping-vision/get-submission-data/list-submission-events) to watch for `SSC` (status change) events
and track submission progress through the workflow.

<Note>
  **Production Architecture**: This polling typically runs as a background
  process watching **all** submissions for your team/division. This demo filters
  to a single `pingid` for clarity.
</Note>

**Example code:**

<CodeGroup dropdown>
  ```python clear_and_triage.py lines icon='python' theme={null}
  # =============================================================================
  # Background Event Monitor (USER ACTION)
  # =============================================================================
  # Poll for submission status changes. In production, this polling typically
  # runs as a background process watching ALL submissions for your team/division.
  # This demo filters to a single pingid for clarity and to show clear
  # cause-and-effect in the console output.
  # =============================================================================
  def poll_submission_events(
      base_url: str,
      headers: dict,
      team_uuid: str,
      pingid: str,
      earliest_allowed_time: datetime,
  ):
      """
      Background polling loop that watches for submission status-change events.

      This function is meant to be run in a daemon thread. It calls the
      list_submission_events endpoint every POLL_INTERVAL seconds and updates
      the shared *db* dict with the latest status UUID for the pingid it sees.

      Status-change events are printed to the console as they arrive so you
      can observe the submission progressing through the workflow in real time.

      Note: This demo polls for a single pingid for clarity. In production, you
      would omit the pingid filter and track all submissions, potentially routing
      events to different handlers or queues based on the submission's pingid.
      """
      submission_events_url = f"{base_url}/submission-events"
      last_cursor_id = None

      while True:
          time.sleep(POLL_INTERVAL)
          try:
              events_params = {
                  "pingid": pingid, # (pingid filter typically omitted)
                  "start": earliest_allowed_time.strftime("%Y%m%d%H%M%S"),
                  "page_size": 50,
                  "team": team_uuid, # as needed. can filter by division, team, or nothing if you want 'everything'.
              }
              if last_cursor_id:
                  events_params["cursor_id"] = last_cursor_id

              response = requests.get(submission_events_url, params=events_params, headers=headers)
              if response.status_code not in (200, 201):
                  print(color_text(f"Error polling events: {response.status_code}", "red"))
                  continue

              events_json = response.json()
              last_cursor_id = events_json.get("cursor_id") or last_cursor_id

              for event in events_json.get("results", []):
                  if event.get("event_type") == "SSC":  # Submission Status Change
                      new_status = event.get("new_value", "")
                      db[pingid] = new_status
                      print(
                          color_text(f"Status change: {event.get('message', '')}", "green"),
                          event.get("old_value", ""),
                          "->",
                          new_status,
                      )
          except Exception as e:
              print(color_text(f"Error in event polling: {e}", "red"))

  ```

  ```shell cURL output icon='square-terminal' theme={null}
  curl --request GET \
      --url "https://vision.pingintel.com/api/v1/submission-events?pingid={id}&start=20260220000000&page_size=50&team={team_uuid}&division={division_uuid}" \
      --header "Authorization: Token <your_api_key_here>"
  ```
</CodeGroup>

**Example response** *(truncated)*:

<CodeGroup dropdown>
  ```json JSON output lines icon='json' theme={null}
  {
    "cursor_id": "019c7d25-8290-7e4b-a094-1c880f73c534",
    "results": [
      {
        "event_type": "SSC",
        "message": "Submission status was changed from Initializing to Pending Clearance",
        "new_value": "019c6d26-4e1d-7b8a-bad1-c05ed808dc4d",
        "pingid": "p-mk-tempo-esw5ab",
        "...":  "..."
      }
      "...",
    ]
  }
  ```
</CodeGroup>

### 4. Change Submission Status

<span style={{ color: "#06A77C", fontWeight: "bold" }}>PING</span>

Advance the submission through workflow stages using [Change Submission Status](/ping-vision/update-submission/change-submission-status).
The clearance team reviews for conflicts, then advances to "Cleared" and "Data Entry".

**Example code:**

<CodeGroup dropdown>
  ```python clear_and_triage.py lines icon='python' theme={null}
  def change_status(base_url: str, headers: dict, pingid: str, new_status_uuid: str):
      """
      Change the workflow status of a submission.

      Calls the change_status endpoint to transition the submission to a new
      workflow stage. Use this to programmatically advance submissions through
      clearance, data entry, underwriting, etc.
      """
      change_status_url = f"{base_url}/submission/{pingid}/change_status"
      response = requests.patch(
          change_status_url,
          json={"workflow_status_uuid": new_status_uuid},
          headers=headers,
      )
      if response.status_code not in (200, 201):
          raise RuntimeError(f"Failed to change status: {response.status_code}")

  ```

  ```shell cURL output icon='square-terminal' theme={null}
  # Transition to "Cleared"
  curl --request PATCH \
      --url "https://vision.pingintel.com/api/v1/submission/{id}/change_status" \
      --header "Authorization: Token <your_api_key_here>" \
      --header "Content-Type: application/json" \
      --data '{"workflow_status_uuid": "<cleared_status_uuid_here>"}'

  # Transition to "Data Entry"
  curl --request PATCH \
      --url "https://vision.pingintel.com/api/v1/submission/{id}/change_status" \
      --header "Authorization: Token <your_api_key_here>" \
      --header "Content-Type: application/json" \
      --data '{"workflow_status_uuid": "<data_entry_status_uuid_here>"}'
  ```
</CodeGroup>

**Example response:**

<CodeGroup dropdown>
  ```json JSON output lines icon='json' theme={null}
  {
    "status": "success"
  }
  ```
</CodeGroup>

### 5. Ping Prepares Data for Underwriting

<span style={{ color: "#06A77C", fontWeight: "bold" }}>PING</span>

Once in Data Entry, Ping's AI and human-in-the-loop process immediately begins perfecting the SOV data by
reviewing and correcting the extracted data, and finally certifying the submission.
This triggers the transition to "Underwriting" status.

<Warning>
  **Demo Script Note**: The Python demo script below simplifies this step for
  staging/testing by using [Update
  Submission](/ping-vision/update-submission/update-submission-details) to set
  `is_building_data_ready=True` and auto-advance to Underwriting. In production,
  Ping's certification process handles this transition.
</Warning>

**Example code (demo only):**

<CodeGroup dropdown>
  ```python clear_and_triage.py lines icon='python' theme={null}
  def update_submission(base_url: str, headers: dict, pingid: str, attr_to_update: str, value):
      """
      Update an attribute of the submission.

      This calls the Update Submission Details endpoint to modify attributes of a submission.
      In this demo, we use it to set is_building_data_ready=True to simulate Ping's certification step.
      In production, Ping's backend would set this automatically when certification completes.
      """
      update_submission_url = f"{base_url}/submission/{pingid}"
      response = requests.patch(
          update_submission_url,
          json={attr_to_update: value},
          headers=headers,
      )
      if response.status_code not in (200, 201):
          raise RuntimeError(f"Failed to update submission: {response.status_code}")

  # [DEMO ONLY] Mark building data as ready to trigger RUN_OUTPUTTERS

  # In production, this is set automatically when Ping completes correcting the data

  update_submission(BASE_URL, headers, pingid, attr_to_update="is_building_data_ready", value=True)

  ```

  ```shell cURL output icon='square-terminal' theme={null}
  # [DEMO ONLY] Mark building data as ready
  curl --request PATCH \
      --url "https://vision.pingintel.com/api/v1/submission/{id}" \
      --header "Authorization: Token <your_api_key_here>" \
      --header "Content-Type: application/json" \
      --data '{"is_building_data_ready": true}'
  ```
</CodeGroup>

### 6. Wait for Underwriting Status

<span style={{ color: "#0E85E0", fontWeight: "bold" }}>USER ACTION</span>

Poll [List Submission Events](/ping-vision/get-submission-data/list-submission-events) to watch for the submission
to move to "Underwriting" status after Ping completes certification.

**Example code:**

<CodeGroup dropdown>
  ```python clear_and_triage.py lines icon='python' theme={null}
  def wait_for_status(
      pingid: str,
      desired_status: str,
      status_uuids: dict[str, str],
      timeout: int = 600,
  ):
      """
      Block until the submission identified by *pingid* reaches *desired_status*.

      This polls the shared *db* dict (populated by poll_submission_events) and
      compares the current status UUID against the UUID of *desired_status*.
      Progress is printed to the console every 6 seconds.
      """
      desired_status_uuid = status_uuids.get(desired_status)
      start = time.time()

      while time.time() - start < timeout:
          status_uuid = db.get(pingid)
          if status_uuid == desired_status_uuid:
              print(color_text(f"Status for {pingid} reached: {desired_status}", "green"))
              return

          current_status_name = next((k for k, v in status_uuids.items() if v == status_uuid), "unknown")
          print(
              f"Waiting for {pingid} to reach {color_text(desired_status, 'green')} "
              f"(current: {color_text(current_status_name, 'yellow')})"
          )
          time.sleep(6)

      raise TimeoutError(f"Timed out waiting for {pingid} to reach '{desired_status}' after {timeout}s")

  # Usage: wait for human certification to complete

  wait_for_status(pingid, "Underwriting", status_uuids)

  ```

  ```shell cURL output icon='square-terminal' theme={null}
  # Poll until status changes to "Underwriting"
  curl --request GET \
      --url "https://vision.pingintel.com/api/v1/submission-events?pingid={id}&start=20260220000000&page_size=50" \
      --header "Authorization: Token <your_api_key_here>"
  ```
</CodeGroup>

**Example response** *(truncated)*:

<CodeGroup dropdown>
  ```json JSON output lines icon='json' theme={null}
  {
    "cursor_id": "019c7d3e-dd84-74f1-a7a1-cc41ee75ad8e",
    "results": [
      {
        "event_type": "SSC",
        "message": "Submission status was changed from Data Entry to Underwriting",
        "pingid": "p-mk-tempo-esw5ab",
        "...":  "..."
      },
      "...",
    ]
  }
  ```
</CodeGroup>

### 7. Listen for Outputters Complete Event to Download Latest Outputs

<span style={{ color: "#0E85E0", fontWeight: "bold" }}>USER ACTION</span>

Using the same event polling from Step 3, listen for `OC` (Outputters Complete) events via
[List Submission Events](/ping-vision/get-submission-data/list-submission-events).
When Ping finishes generating output files, an `OC` event is emitted containing download URLs
for the final outputs. The event metadata includes the `submission_status` at the time of completion,
which informs on how to handle the outputs.

**Example code:**

<CodeGroup dropdown>
  ```python clear_and_triage.py lines icon='python' theme={null}
  # =============================================================================
  # Background Event Monitor for Outputters Complete (USER ACTION)
  # =============================================================================
  # Poll for OC (Outputters Complete) events. Uses the same polling pattern as
  # Step 3 but listens for a different event type. In production, both SSC and
  # OC handlers would live in a single polling loop.
  # =============================================================================
  def poll_for_outputters_complete(
      base_url: str,
      headers: dict,
      team_uuid: str,
      earliest_allowed_time: datetime,
  ):
      """
      Background polling loop that watches for Outputters Complete events.

      This function is meant to be run in a daemon thread. It calls the
      list_submission_events endpoint every POLL_INTERVAL seconds and handles
      OC events by downloading the generated output files.

      The event metadata includes the submission_status at the time of
      completion, which informs on how to process the outputs:
        - "Data Entry": fetch scores for the submission
        - "Underwriting": download the final output documents
      """
      submission_events_url = f"{base_url}/submission-events"
      last_cursor_id = None

      while True:
          time.sleep(POLL_INTERVAL)
          try:
              events_params = {
                  "start": earliest_allowed_time.strftime("%Y%m%d%H%M%S"),
                  "page_size": 50,
                  "team": team_uuid,
              }
              if last_cursor_id:
                  events_params["cursor_id"] = last_cursor_id

              response = requests.get(submission_events_url, params=events_params, headers=headers)
              if response.status_code not in (200, 201):
                  print(color_text(f"Error polling events: {response.status_code}", "red"))
                  continue

              events_json = response.json()
              last_cursor_id = events_json.get("cursor_id") or last_cursor_id

              for event in events_json.get("results", []):
                  event_type = event.get("event_type")
                  event_pingid = event.get("pingid")

                  if event_type == "OC":  # Outputters Complete
                      metadata = event.get("metadata", {})
                      outputs = metadata.get("outputs", [])
                      submission_status = metadata.get("submission_status", "")
                      sudid = metadata.get("sudid", "")

                      print(
                          color_text(f"Outputters complete: {event.get('message', '')}", "green"),
                      )

                      if submission_status in ["Underwriting"]:
                          print(color_text(f"Downloading {len(outputs)} output(s) for {sudid=} ...", "yellow"))
                          for output in outputs:
                              output_format = output.get("output_format")
                              scrubbed_filename = output.get("scrubbed_filename")
                              output_path = f"{sudid}_{scrubbed_filename}"
                              url = output.get("url")

                              print(f"    Downloading {output_format}: {scrubbed_filename} from {color_text(url, 'blue')}")
                              download_document(
                                  headers=headers,
                                  url=url,
                                  output_path=output_path,
                              )

          except Exception as e:
              print(color_text(f"Error in event polling: {e}", "red"))

  ```

  ```shell cURL output icon='square-terminal' theme={null}
  # Poll for OC (Outputters Complete) events
  curl --request GET \
      --url "https://vision.pingintel.com/api/v1/submission-events?start=20260220000000&page_size=50&team={team_uuid}" \
      --header "Authorization: Token <your_api_key_here>"
  ```
</CodeGroup>

**Example response** *(truncated)*:

<CodeGroup dropdown>
  ```json JSON output lines icon='json' theme={null}
  {
    "cursor_id": "019c7d3e-dd84-74f1-a7a1-cc41ee75ad8e",
    "results": [
      {
        "uuid": "019d73c0-2a2e-766f-bdfb-45a9bea7c213",
        "created_time": "2026-04-09T19:37:46.798287Z",
        "event_type": "OC",
        "message": "Outputters complete",
        "pingid": "p-mk-tempo-esw5ab",
        "division_uuid": "12345678-abc1-cde2-efg3-123456789abcd",
        "new_value": null,
        "old_value": null,
        "user_id": 13,
        "metadata": {
          "job_id": "93af186e-344b-11f1-91fb-922cce342ed8",
          "submission_status": "Underwriting",
          "sudid": "019c7d3e-0001-7abc-9000-abcdef123456",
          "outputs": [
            {
              "label": "Ping SOV",
              "output_format": "AirModeler",
              "scrubbed_filename": "Acme Corp 2026-02-AirModeler.xlsm",
              "url": "https://vision.pingintel.com/api/v1/sov/019c7d3e-0001-7abc-9000-abcdef123456/output/Acme%20Corp%202026-02-AirModeler.xlsm"
            },
            {
              "label": "JSON",
              "output_format": "JSON",
              "scrubbed_filename": "Acme Corp 2026-02.json",
              "url": "https://vision.pingintel.com/api/v1/sov/019c7d3e-0001-7abc-9000-abcdef123456/output/Acme%20Corp%202026-02.json"
            }
          ]
        }
      },
      "...",
    ]
  }
  ```
</CodeGroup>

### 8. Download Final Output Documents

<span style={{ color: "#0E85E0", fontWeight: "bold" }}>USER ACTION</span>

Downloads are handled automatically by the `OC` event handler in Step 7. When the event poller
receives an Outputters Complete event, it extracts the download URLs from the event metadata
and saves each output file locally.

**Example code:**

<CodeGroup dropdown>
  ```python clear_and_triage.py lines icon='python' theme={null}
  def download_document(
      headers: dict,
      url: str,
      output_path: str,
  ):
      """
      Download a document from a submission.
      Fetches the document content and writes it to *output_path*.
      """
      response = requests.get(url, headers=headers)
      if response.status_code not in (200, 201):
          raise RuntimeError(f"Failed to download {output_path}: {response.status_code}")
      os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)

      with open(output_path, "wb") as f:
          f.write(response.content)

  ```

  ```shell cURL output icon='square-terminal' theme={null}
  # Download using the URL from the OC event metadata:
  curl --request GET \
      --url "https://vision.pingintel.com/api/v1/sov/{sudid}/output/{filename}" \
      --header "Authorization: Token <your_api_key_here>" \
      --output "{filename}"
  ```
</CodeGroup>

**Example response:**

<CodeGroup dropdown>
  ```python Python output lines icon='python' theme={null}
  Downloading AirModeler: Acme Corp 2026-02-AirModeler.xlsm from https://vision.pingintel.com/api/v1/sov/019c7d3e-0001-7abc-9000-abcdef123456/output/Acme%20Corp%202026-02-AirModeler.xlsm
  Downloading JSON: Acme Corp 2026-02.json from https://vision.pingintel.com/api/v1/sov/019c7d3e-0001-7abc-9000-abcdef123456/output/Acme%20Corp%202026-02.json

  ```

  ```shell cURL output icon='square-terminal' theme={null}
  # Files downloaded to current directory:
  # - Acme Corp 2026-02-AirModeler.xlsm
  # - Acme Corp 2026-02.json
  ```
</CodeGroup>

### Python Demo

           
    

<a href="https://raw.githubusercontent.com/pingintel/pingintel-api/main/examples/ping-vision/clear_and_triage_api.py" download>
  Download Python Script here
</a>

|||

<a href="https://raw.githubusercontent.com/pingintel/pingintel-api/main/examples/data/demo_email.eml" download>
  Download Example Email here
</a>

<CodeGroup dropdown>
  ```python clear_and_triage_api.py lines icon='python' theme={null}
  """
  Ping.Vision Clearance & Triage Workflow — Raw API Example

  This script demonstrates an end-to-end clearance workflow using direct HTTP requests
  to the Ping.Vision API. It uploads a submission (.eml file), monitors status changes
  via polling, programmatically advances the submission through clearance stages,
  waits for human certification, and finally downloads the finished SOV Fixer outputs.

  This is the "raw requests" version of pingvision_clear_and_triage.py, intended to
  show exactly what HTTP calls are being made without the abstraction of the
  pingintel_api client library.

  Prerequisites:

  - A valid Ping.Vision API auth token set in PINGVISION_AUTH_TOKEN_LOCAL environment variable
  - Network access to the Ping.Vision instance at the configured BASE_URL
  - An .eml file containing the submission you want to process

  Usage:
  python clear_and_triage_api.py --eml /path/to/submission.eml --company "Acme Corp" --team "Acme Corp"
  python clear_and_triage_api.py --eml demo_email.eml --company "Ping Intel" --team "Ping Intel" --api-url "http://localhost:8002"
  """

  import argparse
  import os
  import threading
  import time
  from datetime import datetime, timezone
  from pathlib import Path

  import requests

  # How often to poll for status changes (in seconds)

  POLL_INTERVAL = 15

  # Shared state dictionary for tracking submission statuses across threads

  # Maps pingid -> current status UUID

  db: dict[str, str] = {}

  def color*text(text: str, color: str) -> str:
  """
  Wrap \_text* in ANSI escape codes so it renders in the given _color_ when
  printed to a terminal.

      Supported colors: "red", "yellow", "green". If an unsupported color is
      passed the text is returned unmodified.
      """
      colors = {"red": "\033[31m", "yellow": "\033[33m", "green": "\033[32m"}
      if color not in colors:
          return text
      reset = "\033[0m"
      return f"{colors[color]}{text}{reset}"

  def authenticate(api_url: str, auth_token: str) -> dict:
  """
  Authenticate to the Ping.Vision API and return the necessary headers and base URL.
  """
  if auth_token is None:
  auth_token = os.environ.get("PINGVISION_AUTH_TOKEN_LOCAL")
  if not auth_token:
  raise RuntimeError("No auth token provided. Set PINGVISION_AUTH_TOKEN_LOCAL or pass --auth-token")

      base_url = api_url.rstrip("/")
      if not base_url.endswith("/api/v1"):
          base_url = f"{base_url}/api/v1"

      headers = {"Authorization": f"Token {auth_token}"}
      return headers, base_url

  def get_team(base_url: str, headers: dict, company_name: str, team_name: str) -> dict:
  """
  Look up a Ping.Vision team by its company and team display names.

      This calls the Ping.Vision list_teams endpoint and searches for an exact
      match on both company_name and team_name. Use it early in your workflow
      to obtain the team_uuid and division_uuid you'll need for submission
      creation and status lookups.

      If the team is not found, all available teams are printed to the console
      to help you identify the correct names, and a RuntimeError is raised.
      """
      list_teams_url = f"{base_url}/user/teams/"
      response = requests.get(list_teams_url, headers=headers)
      if response.status_code not in (200, 201):
          raise RuntimeError(f"Failed to list teams: {response.status_code}")

      teams = response.json()
      for team in teams:
          if team["company_name"] == company_name and team["team_name"] == team_name:
              return team

      print("Available teams:")
      for team in teams:
          print(f"  * {team['company_name']} / {team['team_name']} ({team['team_uuid']})")
      raise RuntimeError(f"Team not found: {company_name} / {team_name}")

  def get_statuses(base_url: str, headers: dict, division_uuid: str) -> dict[str, str]:
  """
  Retrieve the workflow statuses configured for a division and return as a name->uuid mapping.

      This calls the list_submission_statuses endpoint to get all available workflow
      statuses (e.g., "Received", "Pending Clearance", "Cleared", "Data Entry", etc.).

      Returns a dict mapping status names to their UUIDs for easy lookup when
      transitioning submissions between stages.
      """
      statuses_url = f"{base_url}/submission-status"
      params = {"division": division_uuid}
      response = requests.get(statuses_url, params=params, headers=headers)
      if response.status_code not in (200, 201):
          raise RuntimeError(f"Failed to list statuses: {response.status_code}")

      statuses = response.json()
      return {s["name"]: s["uuid"] for s in statuses}

  def create_submission(
  base_url: str,
  headers: dict,
  team_uuid: str,
  file_path: str,
  insured_name: str = "Acme Corp",
  client_ref: str = None,
  ) -> str:
  """
  Upload files to create a new submission.
  Returns the pingid of the created submission.
  """
  with open(file_path, "rb") as eml_file:
  files = [("files", (Path(file_path).name, eml_file))]
  payload = {
  "team_uuid": team_uuid,
  "insured_name": insured_name,
  }
  if client_ref:
  payload["client_ref"] = client_ref
  create_submission_url = f"{base_url}/submission"
  response = requests.post(create_submission_url, data=payload, files=files, headers=headers)
  if response.status_code not in (200, 201):
  raise RuntimeError(f"Failed to create submission: {response.status_code}")

      return response.json()["id"]

  # =============================================================================

  # Background Event Monitor (USER ACTION)

  # =============================================================================

  # Poll for submission status changes. In production, this polling typically

  # runs as a background process watching ALL submissions for your team/division.

  # This demo filters to a single pingid for clarity and to show clear

  # cause-and-effect in the console output.

  # =============================================================================

  def poll_submission_events(
  base_url: str,
  headers: dict,
  team_uuid: str,
  earliest_allowed_time: datetime,
  ):
  """
  Background polling loop that watches for submission events.

      This function is meant to be run in a daemon thread. It calls the
      list_submission_events endpoint every POLL_INTERVAL seconds and handles:
        - SSC (Submission Status Change): updates the shared *db* dict with the
          latest status UUID for each pingid.
        - OC (Outputters Complete): downloads output files when they are ready.

      Events are printed to the console as they arrive so you can observe
      submissions progressing through the workflow in real time.
      """
      submission_events_url = f"{base_url}/submission-events"
      last_cursor_id = None

      while True:
          time.sleep(POLL_INTERVAL)
          try:
              events_params = {
                  "start": earliest_allowed_time.strftime("%Y%m%d%H%M%S"),
                  "page_size": 50,
                  "team": team_uuid,
              }
              if last_cursor_id:
                  events_params["cursor_id"] = last_cursor_id

              response = requests.get(submission_events_url, params=events_params, headers=headers)
              if response.status_code not in (200, 201):
                  print(color_text(f"Error polling events: {response.status_code}", "red"))
                  continue

              events_json = response.json()
              last_cursor_id = events_json.get("cursor_id") or last_cursor_id

              for event in events_json.get("results", []):
                  event_type = event.get("event_type")
                  event_pingid = event.get("pingid")

                  if event_type == "SSC":  # Submission Status Change
                      new_status = event.get("new_value", "")
                      db[event_pingid] = new_status
                      print(
                          color_text(f"Status change: {event.get('message', '')}", "green"),
                          event.get("old_value", ""),
                          "->",
                          new_status,
                      )

                  elif event_type == "OC":  # Outputters Complete
                      metadata = event.get("metadata", {})
                      outputs = metadata.get("outputs", [])
                      submission_status = metadata.get("submission_status", "")
                      sudid = metadata.get("sudid", "")

                      print(
                          color_text(f"Outputters complete: {event.get('message', '')}", "green"),
                      )

                      if submission_status in ["Underwriting"]:
                          print(color_text(f"Downloading {len(outputs)} output(s) for {sudid=} ...", "yellow"))
                          for output in outputs:
                              output_format = output.get("output_format")
                              scrubbed_filename = output.get("scrubbed_filename")
                              output_path = f"{sudid}_{scrubbed_filename}"
                              url = output.get("url")

                              print(f"    Downloading {output_format}: {scrubbed_filename} from {color_text(url, 'blue')}")
                              download_document(
                                  headers=headers,
                                  url=url,
                                  output_path=output_path,
                              )


          except Exception as e:
              print(color_text(f"Error in event polling: {e}", "red"))

  def wait*for_status(
  pingid: str,
  desired_status: str,
  status_uuids: dict[str, str],
  timeout: int = 600,
  ):
  """
  Block until the submission identified by \_pingid* reaches _desired_status_.

      This polls the shared *db* dict (populated by poll_submission_events) and
      compares the current status UUID against the UUID of *desired_status*.
      Progress is printed to the console every 6 seconds.
      """
      desired_status_uuid = status_uuids.get(desired_status)
      start = time.time()

      while time.time() - start < timeout:
          status_uuid = db.get(pingid)
          if status_uuid == desired_status_uuid:
              print(color_text(f"Status for {pingid} reached: {desired_status}", "green"))
              return

          current_status_name = next((k for k, v in status_uuids.items() if v == status_uuid), "unknown")
          print(
              f"Waiting for {pingid} to reach {color_text(desired_status, 'green')} "
              f"(current: {color_text(current_status_name, 'yellow')})"
          )
          time.sleep(6)

      raise TimeoutError(f"Timed out waiting for {pingid} to reach '{desired_status}' after {timeout}s")

  def change_status(base_url: str, headers: dict, pingid: str, new_status_uuid: str):
  """
  Change the workflow status of a submission.

      Calls the change_status endpoint to transition the submission to a new
      workflow stage. Use this to programmatically advance submissions through
      clearance, data entry, underwriting, etc.
      """
      change_status_url = f"{base_url}/submission/{pingid}/change_status"
      response = requests.patch(
          change_status_url,
          json={"workflow_status_uuid": new_status_uuid},
          headers=headers,
      )
      if response.status_code not in (200, 201):
          raise RuntimeError(f"Failed to change status: {response.status_code}")

  def update_submission(base_url: str, headers: dict, pingid: str, attr_to_update: str, value):
  """
  Update a property of the submission.

          This is a critical step in the clearance workflow. When is_building_data_ready
          is set to True, it signals that:
            - The SOV data has been parsed and validated
            - Addresses have been geocoded
            - Third-party enrichment data has been attached
            - The submission is ready for underwriting review

          In production, this flag is typically set automatically by Ping's certification
          process after a human reviewer has verified the data quality. For demo/testing
          purposes, this can be set programmatically to advance the workflow.

          This triggers the RUN_OUTPUTTERS job which generates the final output files.
      """
      update_submission_url = f"{base_url}/submission/{pingid}"
      response = requests.patch(
          update_submission_url,
          json={attr_to_update: value},
          headers=headers,
      )
      if response.status_code not in (200, 201):
          raise RuntimeError(f"Failed to update submission: {response.status_code}")

  def download*document(
  headers: dict,
  url: str,
  output_path: str,
  ):
  """
  Download a document from a submission.
  Fetches the document content and writes it to \_output_path*.
  """
  response = requests.get(url, headers=headers)
  if response.status_code not in (200, 201):
  raise RuntimeError(f"Failed to download {output_path}: {response.status_code}")
  os.makedirs(os.path.dirname(output_path) or ".", exist_ok=True)

      with open(output_path, "wb") as f:
          f.write(response.content)

  def run_clearance_workflow(
  \*,
  eml_path: str,
  company_name: str,
  team_name: str,
  api_url: str = "http://localhost:8002/api/v1",
  auth_token: str | None = None,
  ) -> str:
  """
  Execute the full clearance workflow for a single .eml submission.

      This is the main entry point. It performs the following steps:

        1. Resolves the team and division from Ping.Vision.
        2. Uploads the .eml file as a new submission (USER ACTION).
        3. Starts a background thread to monitor events — SSC and OC (USER ACTION).
        4. Waits for "Pending Clearance", advances to "Cleared" and "Data Entry" (PING).
        5. Ping prepares data for underwriting (PING - demo simplifies via Update Submission API).
        6. Waits for "Underwriting" status (USER ACTION polling).
        7-8. Background poller listens for OC events and downloads outputs automatically.

      Returns the pingid of the created submission.
      """
      # Setup API authentication and base URL
      headers, base_url = authenticate(api_url, auth_token)

      # ==========================================================================
      # Step 1: Get Team and Statuses
      # ==========================================================================
      print(color_text("Step 1: Looking up team and workflow statuses...", "yellow"))

      team = get_team(base_url, headers, company_name, team_name)
      team_uuid = team["team_uuid"]
      division_uuid = team["division_uuid"]
      print(f"  Team: {team['team_name']} ({team_uuid})")

      status_uuids = get_statuses(base_url, headers, division_uuid)
      print(f"  Found {len(status_uuids)} workflow statuses")

      # ==========================================================================
      # Step 2: Create Submission
      # ==========================================================================
      # USER ACTION: A broker or underwriter uploads a submission (e.g., an email
      # with SOV attachment) to Ping.Vision for processing. This kicks off the
      # automated intake and clearance workflow.
      # ==========================================================================
      print(color_text("\nStep 2: Creating submission...", "yellow"))

      pingid = create_submission(base_url, headers, team_uuid, eml_path)

      print(color_text(f"  Created submission: {pingid}", "green"))

      # ==========================================================================
      # Step 3: Start Background Event Polling
      # ==========================================================================
      # USER ACTION: Start polling for submission events to track status changes.
      # In production, this typically runs as a background process watching all
      # submissions for your team/division.
      # ==========================================================================
      print(color_text("\nStep 3: Starting background event polling...", "yellow"))

      earliest_allowed_time = datetime.now(timezone.utc)

      threading.Thread(
          target=poll_submission_events,
          args=(
              base_url,
              headers,
              team_uuid,
              earliest_allowed_time,
          ),
          name="event_poller",
          daemon=True,
      ).start()

      # ==========================================================================
      # Step 4: Wait for Pending Clearance, then Advance to Data Entry
      # ==========================================================================
      # PING: The clearance team reviews the submission for conflicts (e.g.,
      # incumbent carriers, duplicate submissions). If cleared, they advance
      # through "Cleared" to "Data Entry" where Ping's AI processing begins.
      # ==========================================================================
      print(color_text("\nStep 4: Waiting for Pending Clearance status...", "yellow"))

      wait_for_status(pingid, "Pending Clearance", status_uuids)

      print(color_text("\n  Advancing to Cleared...", "yellow"))
      change_status(base_url, headers, pingid, status_uuids["Cleared"])
      wait_for_status(pingid, "Cleared", status_uuids)

      print(color_text("\n  Advancing to Data Entry...", "yellow"))
      change_status(base_url, headers, pingid, status_uuids["Data Entry"])
      wait_for_status(pingid, "Data Entry", status_uuids)

      # ==========================================================================
      # Step 5: Ping Corrects Data for Underwriting
      # ==========================================================================
      # PING: Ping's AI and human-in-the-loop process downloads the scrubber,
      # reviews/corrects the extracted data, and certifies the submission.
      # This triggers the transition to Underwriting.
      #
      # For the sake of this demo script, we simplify this step:
      #   - Only do this in staging, not production
      #   - Uses Update Submission API with is_building_data_ready=True
      # ==========================================================================
      print(color_text("\nStep 5: Ping corrects data...", "yellow"))
      print(color_text("  (In production: Ping's team corrects the data)", "red"))

      # [DEMO] Auto-advance to Underwriting and mark data as ready
      # In production, this is triggered by Ping's certification process
      print(color_text("  [DEMO] Auto-advancing to Underwriting...", "yellow"))
      change_status(base_url, headers, pingid, status_uuids["Underwriting"])

      # Mark building data as ready - this triggers the RUN_OUTPUTTERS job
      # In production, this flag is set when Ping's certification is complete
      print(color_text("  [DEMO] Marking building data as ready...", "yellow"))
      update_submission(base_url, headers, pingid, to_update="is_building_data_ready", value=True)

      # ==========================================================================
      # Step 6: Wait for Underwriting Status
      # ==========================================================================
      # USER ACTION: Poll submission events to watch for the transition to
      # "Underwriting" status after Ping completes certification.
      # ==========================================================================
      wait_for_status(pingid, "Underwriting", status_uuids)

      # ==========================================================================
      # Step 7 & 8: Listen for Outputters Complete and Download Outputs
      # ==========================================================================
      # The background event poller (Step 3) handles OC (Outputters Complete)
      # events automatically — when outputs are ready, it downloads them.
      # No additional action needed here. The poller thread runs until the
      # process exits.
      # ==========================================================================
      print(color_text("\nSteps 7-8: Waiting for OC events (handled by background poller)...", "yellow"))
      print(color_text("  Output downloads will appear as OC events arrive.", "green"))

      # Keep the main thread alive so the daemon poller can continue processing
      while True:
          time.sleep(60)

      print(color_text(f"\n✓ Workflow complete for submission {pingid}", "green"))
      return pingid

  if **name** == "**main**":
  parser = argparse.ArgumentParser(
  description="Run Ping.Vision clearance workflow using raw HTTP requests",
  formatter_class=argparse.RawDescriptionHelpFormatter,
  epilog="""
  Examples:
  python clear_and_triage_api.py --eml demo_email.eml --company "Ping Intel" --team "Ping Intel"
  python clear_and_triage_api.py --eml submission.eml --company "Acme" --team "Acme" --api-url "http://localhost:8002"
  """,
  )
  parser.add_argument("--eml", required=True, help="Path to .eml file to submit")
  parser.add_argument("--company", required=True, help="Company name in Ping.Vision")
  parser.add_argument("--team", required=True, help="Team name in Ping.Vision")
  parser.add_argument(
  "--api-url",
  default="http://localhost:8002",
  help="Ping.Vision API base URL (default: localhost:8002)",
  )
  parser.add_argument(
  "--auth-token",
  default=None,
  help="API auth token (default: PINGVISION_AUTH_TOKEN_LOCAL env var)",
  )

      args = parser.parse_args()

      try:
          pingid = run_clearance_workflow(
              eml_path=args.eml,
              company_name=args.company,
              team_name=args.team,
              api_url=args.api_url,
              auth_token=args.auth_token,
          )
      except KeyboardInterrupt:
          print(color_text("\nWorkflow interrupted by user", "yellow"))
      except Exception as e:
          print(color_text(f"\nError: {e}", "red"))
          raise

  ```
</CodeGroup>

```
```
