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

# Route claims evidence for review with Box Automate

> Build a claims service that runs an automated approval workflow. Box handles the approval or rejection of tasks. Your app chooses the files and starts the review.

export const RelatedLinks = ({title, items = []}) => {
  const getBadgeClass = badge => {
    if (!badge) return "badge-default";
    const badgeType = badge.toLowerCase().replace(/\s+/g, "-");
    return `badge-${badge === "ガイド" ? "guide" : badgeType}`;
  };
  if (!items || items.length === 0) {
    return null;
  }
  return <div className="my-8">
      {}
      <h3 className="text-sm font-bold uppercase tracking-wider mb-4">{title}</h3>

      {}
      <div className="flex flex-col gap-3">
        {items.map((item, index) => <a key={index} href={item.href} className="py-2 px-3 rounded related_link hover:bg-[#f2f2f2] dark:hover:bg-[#111827] flex items-center gap-3 group no-underline hover:no-underline border-b-0">
            {}
            <span className={`px-2 py-1 rounded-full text-xs font-semibold uppercase tracking-wide flex-shrink-0 ${getBadgeClass(item.badge)}`}>
              {item.badge}
            </span>

            {}
            <span className="text-base">{item.label}</span>
          </a>)}
      </div>
    </div>;
};

export const Link = ({href, children, className, ...props}) => {
  const localizedHref = localizeLink(href);
  return <a href={localizedHref} className={className} {...props}>
      {children}
    </a>;
};

A claims system collects evidence into a case folder: photos, police reports, repair estimates, medical records. Someone has to decide whether that evidence is complete and acceptable. That decision then has to be recorded.

This tutorial hands the review to Box. When your app calls `POST /reviews` with selected evidence file IDs, the service tags those files with the claim ID and begins a Box Automate workflow. Box Automate assigns the approval task, waits for the decision, then runs the approved or rejected branch.

<Card title="Clone the working sample" href="https://github.com/box-community/Route-claims-evidence-for-review" icon="github" arrow="true">
  Prefer to start from running code? The complete app built in this tutorial is on GitHub. Clone it, add your Box credentials, and run.
</Card>

<Warning>
  The Box Automate workflow endpoints used in this tutorial are **Beta** and are not available on the [Free Developer Plan](/guides/getting-started/free-developer-plan). Every request must include the `box-version: 2026.0` header. Beta endpoints can change before general availability.
</Warning>

## What you are building

By the end of this tutorial, you have a working Python service that:

* Exposes a `POST /reviews` endpoint your claims system uses to start a review on selected evidence files.
* Writes the claim ID onto each selected evidence file as Box metadata, so the workflow can read it as a variable.
* Looks up the published Automate workflow attached to the claims folder and starts it on the selected files.
* Receives a webhook when the reviewer approves or rejects, and patches `reviewStatus` on each file to `approved` or `rejected`.

Box Automate owns the review itself. Your service never polls for task state or implements approval logic. A V2 webhook closes the loop by writing the decision back to the file's metadata.

<Info>
  **Why a webhook updates review status.** Box Automate assigns the approval task and runs approved or rejected branches, but it does not write file metadata when the task completes. Outbound custom HTTPS actions from Automate to your API are blocked for many URLs. A V2 webhook on `TASK_ASSIGNMENT.UPDATED` lets your service receive the decision and patch `reviewStatus` through the Metadata API.
</Info>

| Component                     | Purpose                                                           | API                                             |
| ----------------------------- | ----------------------------------------------------------------- | ----------------------------------------------- |
| **Automate workflow actions** | Find the published workflow attached to the claims folder         | `GET /2.0/automate_workflows`                   |
| **Manual start**              | Start the approve and reject tasks on the selected evidence files | `POST /2.0/automate_workflows/:id/start`        |
| **Metadata**                  | Carry the claim ID into the workflow as a variable                | `POST /2.0/files/:id/metadata/:scope/:template` |
| **V2 webhooks**               | Receive the approval or rejection and patch `reviewStatus`        | `POST /2.0/webhooks`                            |

<Info>
  **Why metadata carries the claim ID.** During the Beta, the start endpoint does not accept fields at runtime, so you cannot pass a claim ID in the request body. Writing the claim ID to each file as metadata makes it available as a workflow variable. When fields at start become available, you can pass the claim ID directly and drop this step.
</Info>

## Prerequisites

Before you start, make sure you have the following:

* A [Box Enterprise Advanced account](https://www.box.com/pricing) with **Box Automate** enabled. See [Enabling Box Automate](https://docs.box.com/en/box-automate/enabling-box-automate).
* A Box platform application configured with **Client Credentials Grant** authentication. Enable **Read and write all files and folders stored in Box** and **Manage Webhooks**. Set application access to **App + Enterprise Access**, and turn on **Generate user access tokens**. After you change these settings, re-authorize the app in the Admin Console.
* The **user ID** of a managed user who can build and start Automate workflows (for testing, use your own user ID). The Automate list and start endpoints return workflows that user can start. They do not return results for the enterprise service account.
* **Admin access** to create a metadata template, or a Box administrator who can create one for you.
* Permission to build and publish workflows in the Box Automate builder.
* Python 3.11 or higher.
* A coding agent with <Link href="/ai/agent-skills">Box Agent Skills</Link> installed. Run `npx skills add box/box-for-ai` in your project, or install the Cursor, Codex, or Claude Code plugin. See <Link href="/ai/agent-skills">Box Agent Skills</Link> for setup.

<Note>
  Automate and metadata calls require `root_readwrite`, the OAuth scope behind **Read and write all files and folders stored in Box**. Webhook registration requires **Manage Webhooks**. If a scope or feature is missing from the Developer Console, contact Box Support with the user or app context you plan to use.
</Note>

## Configure Box once

These steps happen in the Box Admin Console and Automate builder. An agent cannot do them for you. Complete them before you build the service.

1. **Create a `Claim` metadata template** in the [Admin Console](https://app.box.com/master) under **Metadata**. Add two text fields: Claim ID and Review status. Select **Save**, select the template again, then copy the template key. Confirm the generated field keys with `GET /2.0/metadata_templates/enterprise/<TEMPLATE_KEY>/schema`. This tutorial assumes `claimId` and `reviewStatus`.

2. **Configure Client Credentials Grant for a managed user.** In Developer Console > your CCG app > **Configuration**, enable **Read and write all files and folders stored in Box** and **Manage Webhooks**. Set application access to **App + Enterprise Access**, enable **Generate user access tokens**, and save. Re-authorize the app in the Admin Console. Copy the managed user's ID (Admin Console > Users & Groups, or `GET /2.0/users/me` with that user's token). You pass this ID as `BOX_USER_ID` so the service authenticates as that user, not as the enterprise service account.

   <Warning>
     A service-account token can often read the claims folder and write metadata, yet `GET /2.0/automate_workflows` still returns an empty `entries` list, and start returns `400 Action not found`. Authenticate as a managed user who can run Automate.
   </Warning>

3. **Create a `Claims Review` folder** in Box as that managed user (or invite them as an **Editor**), note its folder ID from the URL, and upload two or three sample PDFs. Without folder access for the user your app acts as, file and metadata calls return 404.

4. **Build and publish a Manual Start workflow** in the Box Automate builder. For a full walkthrough of the builder UI, see [Creating workflows in Box Automate](https://docs.box.com/en/box-automate/creating-workflows-in-box-automate).

   1. Open **Automate**, select **New+** → **Workflow**, and name it `Claims evidence review`.
   2. Drag **Manual Start** onto the canvas and scope it to the `Claims Review` folder.
   3. Add a **Task Action** outcome. Set the type to **Approval**, assign the file to **Trigger: File**, and for testing set both complete and manage to **Workflow owner**. Optionally include the Claim ID metadata field in the task message.
   4. Branch on **Approved** and **Rejected**, and add **Send Notification** on each branch.
   5. Select **Activate** (not only **Save**).

   <Warning>
     The Manual Start folder must match the `folder_id` you query later. An unpublished draft, or a different folder, returns an empty `entries` list rather than an error. `BOX_CLAIMS_WORKFLOW_NAME` must match the published workflow name exactly. The folder display name is unrelated.
   </Warning>

5. **Optional: inspect the workflow IDs** so you recognize the response shape later. Use an access token for the same managed user your app will act as:

```bash theme={null}
curl -X GET "https://api.box.com/2.0/automate_workflows?folder_id=<FOLDER_ID>" \
  -H "box-version: 2026.0" \
  -H "Authorization: Bearer <ACCESS_TOKEN>"
```

| Response field          | Where it goes                   |
| ----------------------- | ------------------------------- |
| `entries[].workflow.id` | Path parameter `workflow_id`    |
| `entries[].id`          | Body field `workflow_action_id` |

The top-level `id` is the **action** ID, not the workflow ID. Passing the action ID in the path returns a 404.

## Build the service

Choose how to scaffold the service. Both paths produce the same app; continue to [Run and verify](#run-and-verify) when you finish.

<Tabs>
  <Tab title="Build with an agent">
    With Box configured and <Link href="/ai/agent-skills">Box Agent Skills</Link> installed, paste the following prompt into your coding agent. Replace the placeholders only if you want the agent to pre-fill `.env`; otherwise leave them and fill credentials yourself after scaffolding.

    ```text theme={null}
    Build a Python Flask service named claims-evidence-review that starts a Box
    Automate manual-start workflow when POST /reviews receives selected evidence
    file IDs.

    Prerequisites already done in Box:
    - Enterprise metadata template key: <TEMPLATE_KEY> (fields claimId, reviewStatus)
    - Folder ID for Manual Start: <FOLDER_ID>
    - Published Automate workflow named exactly: Claims evidence review
    - CCG app has read/write files and Manage Webhooks scopes, App + Enterprise
      Access, and Generate user access tokens enabled and re-authorized
    - Managed user ID for Automate calls: <USER_ID> (not the service account)

    Create these files:

    1. box_client.py — CCG BoxClient from BOX_CLIENT_ID, BOX_CLIENT_SECRET,
       and BOX_USER_ID via python-dotenv. Authenticate as the managed user
       (CCGConfig user_id). Do NOT use enterprise_id / the service account;
       Automate list and start return empty or Action not found for that actor.

    2. automate.py — wrap the Beta Automate APIs with client.make_request
       (Box Python SDK v10 has no automate_workflows manager yet):
       - Always send header box-version: 2026.0
       - GET https://api.box.com/2.0/automate_workflows?folder_id=...
       - Map each entry to WorkflowAction(workflow_id=entry.workflow.id,
         action_id=entry.id, name=entry.workflow.name)
       - find_workflow_action(client, folder_id, workflow_name) raises LookupError
         with a clear message if none match
       - start_workflow POSTs to
         /2.0/automate_workflows/{workflow_id}/start with body
         { "workflow_action_id": action_id, "file_ids": [...] }
       - MAX_FILES_PER_RUN = 20
       - Do NOT use legacy /2.0/workflows or invent folder/flow/outcomes fields

    3. claims_metadata.py — export tag_evidence and replace_metadata. For each
       file_id, create enterprise metadata with { claimId, reviewStatus: "in_review" }.
       On 409 Conflict, call replace_metadata to JSON-Patch replace both fields
       (resubmitted evidence).

    4. app.py — POST /reviews accepts { claim_id, file_ids }. Validate inputs,
       tag metadata first, resolve workflow by BOX_CLAIMS_WORKFLOW_NAME on
       BOX_CLAIMS_FOLDER_ID, start workflow, return 202
       { status, claim_id, workflow, file_ids }.

    5. app.py — also add POST /webhooks/task-updated that receives
       TASK_ASSIGNMENT.UPDATED webhooks from Box, verifies the signature with
       WebhooksManager.validate_message (Box Python SDK v10), reads the decision
       from source.resolution_state and the file ID from source.item.id, normalizes
       the decision to lowercase, and patches reviewStatus on the file to approved
       or rejected using replace_metadata from claims_metadata.py. Env vars:
       BOX_WEBHOOK_PRIMARY_KEY, BOX_WEBHOOK_SECRET_KEY.

    6. register_webhook.py — one-time setup script. Takes a URL argument and
       creates a V2 webhook on BOX_CLAIMS_FOLDER_ID for
       TASK_ASSIGNMENT.UPDATED using client.webhooks.create_webhook.

    7. .env.example and .gitignore (.env excluded). Env vars:
       BOX_CLIENT_ID, BOX_CLIENT_SECRET, BOX_USER_ID,
       BOX_CLAIMS_FOLDER_ID, BOX_CLAIMS_TEMPLATE_KEY,
       BOX_CLAIMS_WORKFLOW_NAME, BOX_WEBHOOK_PRIMARY_KEY,
       BOX_WEBHOOK_SECRET_KEY

    Use Python 3.11+, boxsdk>=10, flask, python-dotenv. Create the project at
    ~/claims-evidence-review, create a venv, install deps, and print the curl
    command to test POST /reviews after I fill .env.
    ```

    When the agent finishes, copy `.env.example` to `.env` and fill in your credentials, user ID, folder ID, and template key. Put each variable on its own line.
  </Tab>

  <Tab title="Build by hand">
    Prefer to write the code yourself, or need a reference when the agent drifts? Complete [Configure Box once](#configure-box-once) first, then follow the steps in order.

    <AccordionGroup>
      <Accordion title="1. Set up the development environment">
        1. Create a project directory:

        ```bash theme={null}
        mkdir ~/claims-evidence-review && cd ~/claims-evidence-review
        ```

        2. Create and activate a Python virtual environment:

        ```bash theme={null}
        python3 -m venv .venv
        source .venv/bin/activate
        ```

        After activation, your terminal prompt shows `(.venv)` at the beginning.

        <Note>
          Every time you open a new terminal window or tab, re-activate with `source .venv/bin/activate`. `ModuleNotFoundError` usually means the venv is not activated.
        </Note>

        3. Install packages:

        ```bash theme={null}
        pip install "boxsdk>=10" flask python-dotenv
        ```

        4. Create a `.env` file:

        ```bash theme={null}
        BOX_CLIENT_ID=your_client_id
        BOX_CLIENT_SECRET=your_client_secret
        BOX_USER_ID=your_managed_user_id
        BOX_CLAIMS_FOLDER_ID=your_claims_review_folder_id
        BOX_CLAIMS_TEMPLATE_KEY=your_metadata_template_key
        BOX_CLAIMS_WORKFLOW_NAME=Claims evidence review
        ```

        <Warning>
          Never commit `.env` files to version control. Add `.env` to your `.gitignore`.
        </Warning>
      </Accordion>

      <Accordion title="2. Authenticate the Box client">
        Create `box_client.py`:

        ```python theme={null}
        import os

        from box_sdk_gen import BoxCCGAuth, BoxClient, CCGConfig
        from dotenv import load_dotenv

        load_dotenv()


        def get_box_client() -> BoxClient:
            config = CCGConfig(
                client_id=os.getenv("BOX_CLIENT_ID"),
                client_secret=os.getenv("BOX_CLIENT_SECRET"),
                user_id=os.getenv("BOX_USER_ID"),
            )
            return BoxClient(auth=BoxCCGAuth(config=config))
        ```

        <Tip>
          Client Credentials Grant with `user_id` authenticates as that managed user. The Automate Manual Start endpoints return workflows that user can start. Do not use `enterprise_id` here: the enterprise service account can access the folder yet still sees an empty Automate list. For other options, see [Select an authentication method](/guides/authentication/select).
        </Tip>
      </Accordion>

      <Accordion title="3. Call the Automate endpoints">
        Create `automate.py`. This module wraps both Automate endpoints and turns the nested list response into a small value object, so the rest of your app never handles the two raw IDs.

        ```python theme={null}
        from dataclasses import dataclass

        from box_sdk_gen import BoxClient
        from box_sdk_gen.networking.fetch_options import FetchOptions, ResponseFormat

        BOX_API_BASE = "https://api.box.com/2.0"
        AUTOMATE_HEADERS = {"box-version": "2026.0"}

        MAX_FILES_PER_RUN = 20


        @dataclass(frozen=True)
        class WorkflowAction:
            """A published Automate workflow that can be started through the API."""

            workflow_id: str
            action_id: str
            name: str


        def list_workflow_actions(client: BoxClient, folder_id: str) -> list[WorkflowAction]:
            response = client.make_request(
                FetchOptions(
                    url=f"{BOX_API_BASE}/automate_workflows",
                    method="GET",
                    params={"folder_id": folder_id},
                    headers=AUTOMATE_HEADERS,
                    response_format=ResponseFormat.JSON,
                )
            )

            return [
                WorkflowAction(
                    workflow_id=entry["workflow"]["id"],
                    action_id=entry["id"],
                    name=entry["workflow"].get("name", ""),
                )
                for entry in response.data.get("entries") or []
            ]


        def find_workflow_action(
            client: BoxClient, folder_id: str, workflow_name: str
        ) -> WorkflowAction:
            actions = list_workflow_actions(client, folder_id)

            if not actions:
                raise LookupError(
                    f"No Automate workflow actions on folder {folder_id}. Confirm the "
                    "workflow is published and its Manual Start trigger uses this folder."
                )

            for action in actions:
                if action.name == workflow_name:
                    return action

            available = ", ".join(sorted(action.name for action in actions))
            raise LookupError(
                f"Workflow {workflow_name!r} not found on folder {folder_id}. "
                f"Available workflows: {available}."
            )


        def start_workflow(
            client: BoxClient, action: WorkflowAction, file_ids: list[str]
        ) -> None:
            client.make_request(
                FetchOptions(
                    url=f"{BOX_API_BASE}/automate_workflows/{action.workflow_id}/start",
                    method="POST",
                    headers=AUTOMATE_HEADERS,
                    data={"workflow_action_id": action.action_id, "file_ids": file_ids},
                    response_format=ResponseFormat.JSON,
                )
            )
        ```

        <Note>
          The Automate endpoints are not yet in the generated Python SDK, so this module uses `client.make_request`. The SDK still handles token refresh, retries, and error mapping to `BoxAPIError`. When the endpoints reach the SDK, swap the two `make_request` calls for generated methods and leave the rest of the app untouched.
        </Note>

        A successful start returns `204 No Content`. Treat a non-raising call as confirmation that Box accepted the run.
      </Accordion>

      <Accordion title="4. Tag evidence with the claim ID">
        Create `claims_metadata.py`. This writes the claim context onto each file before the workflow starts.

        ```python theme={null}
        import os

        from box_sdk_gen import (
            BoxAPIError,
            BoxClient,
            CreateFileMetadataByIdScope,
            UpdateFileMetadataByIdRequestBody,
            UpdateFileMetadataByIdRequestBodyOpField,
            UpdateFileMetadataByIdScope,
        )
        from dotenv import load_dotenv

        load_dotenv()

        HTTP_CONFLICT = 409


        def tag_evidence(client: BoxClient, file_ids: list[str], claim_id: str) -> None:
            """Attach claim context to each evidence file so the workflow can read it."""
            template_key = os.getenv("BOX_CLAIMS_TEMPLATE_KEY")
            values = {"claimId": claim_id, "reviewStatus": "in_review"}

            for file_id in file_ids:
                try:
                    client.file_metadata.create_file_metadata_by_id(
                        file_id=file_id,
                        scope=CreateFileMetadataByIdScope.ENTERPRISE,
                        template_key=template_key,
                        request_body=values,
                    )
                except BoxAPIError as error:
                    if error.response_info.status_code != HTTP_CONFLICT:
                        raise
                    replace_metadata(client, file_id, template_key, values)


        def replace_metadata(
            client: BoxClient, file_id: str, template_key: str, values: dict[str, str]
        ) -> None:
            """Overwrite fields on an existing metadata instance."""
            client.file_metadata.update_file_metadata_by_id(
                file_id=file_id,
                scope=UpdateFileMetadataByIdScope.ENTERPRISE,
                template_key=template_key,
                request_body=[
                    UpdateFileMetadataByIdRequestBody(
                        op=UpdateFileMetadataByIdRequestBodyOpField.REPLACE,
                        path=f"/{key}",
                        value=value,
                    )
                    for key, value in values.items()
                ],
            )
        ```

        A file that already carries an instance of the template returns `409 Conflict` on create. The handler falls back to replacing the values so a restarted review does not fail.
      </Accordion>

      <Accordion title="5. Build the start review endpoint">
        Create `app.py`. This is the endpoint your claims system calls to start a review on selected evidence files.

        ```python theme={null}
        import os

        from box_sdk_gen import BoxAPIError
        from dotenv import load_dotenv
        from flask import Flask, jsonify, request

        from automate import MAX_FILES_PER_RUN, find_workflow_action, start_workflow
        from box_client import get_box_client
        from claims_metadata import tag_evidence

        load_dotenv()
        app = Flask(__name__)


        @app.post("/reviews")
        def start_review():
            payload = request.get_json(silent=True) or {}
            claim_id = payload.get("claim_id")
            file_ids = payload.get("file_ids") or []

            if not claim_id:
                return jsonify({"error": "claim_id is required"}), 400
            if not file_ids:
                return jsonify({"error": "file_ids must list at least one file"}), 400
            if len(file_ids) > MAX_FILES_PER_RUN:
                return jsonify(
                    {"error": f"Box Automate accepts at most {MAX_FILES_PER_RUN} files per run"}
                ), 400

            client = get_box_client()
            folder_id = os.getenv("BOX_CLAIMS_FOLDER_ID")
            workflow_name = os.getenv("BOX_CLAIMS_WORKFLOW_NAME")

            try:
                tag_evidence(client, file_ids, claim_id)
                action = find_workflow_action(client, folder_id, workflow_name)
                start_workflow(client, action, file_ids)
            except LookupError as error:
                return jsonify({"error": str(error)}), 404
            except BoxAPIError as error:
                return jsonify({"error": error.message}), error.response_info.status_code

            return jsonify(
                {
                    "status": "review_started",
                    "claim_id": claim_id,
                    "workflow": action.name,
                    "file_ids": file_ids,
                }
            ), 202


        if __name__ == "__main__":
            app.run(port=5000)
        ```

        Tag metadata before starting the workflow. A workflow variable can only read metadata that already exists on the file.
      </Accordion>

      <Accordion title="6. Close the loop with a webhook handler">
        The workflow assigns a task and runs the approved or rejected branch, but it does not write the decision back to the file's metadata. A <Link href="/guides/webhooks/v2/create-v2">V2 webhook</Link> on `TASK_ASSIGNMENT.UPDATED` closes the loop.

        Add the webhook endpoint to `app.py`, below the existing `/reviews` route:

        ```python theme={null}
        from box_sdk_gen.managers.webhooks import WebhooksManager
        from claims_metadata import replace_metadata, tag_evidence

        VALID_DECISIONS = {"approved", "rejected"}


        @app.post("/webhooks/task-updated")
        def handle_task_updated():
            body = request.get_data(as_text=True)
            headers = {k.lower(): v for k, v in request.headers}

            primary_key = os.getenv("BOX_WEBHOOK_PRIMARY_KEY", "")
            secondary_key = os.getenv("BOX_WEBHOOK_SECRET_KEY", "")

            if not WebhooksManager.validate_message(
                body, headers, primary_key, secondary_key=secondary_key
            ):
                return jsonify({"error": "invalid signature"}), 403

            payload = request.get_json(silent=True) or {}
            trigger = payload.get("trigger", "")
            if trigger != "TASK_ASSIGNMENT.UPDATED":
                return "", 200

            source = payload.get("source", {})
            resolution = source.get("resolution_state", "").lower()
            if resolution not in VALID_DECISIONS:
                return "", 200

            file_id = source.get("item", {}).get("id")
            if not file_id:
                return "", 200

            client = get_box_client()
            template_key = os.getenv("BOX_CLAIMS_TEMPLATE_KEY")
            replace_metadata(client, file_id, template_key, {"reviewStatus": resolution})

            return "", 200
        ```

        Finally, create `register_webhook.py` to register the webhook once per environment:

        ```python theme={null}
        import os
        import sys

        from box_sdk_gen.managers.webhooks import (
            CreateWebhookTarget,
            CreateWebhookTargetTypeField,
            CreateWebhookTriggers,
        )
        from dotenv import load_dotenv

        from box_client import get_box_client

        load_dotenv()


        def main():
            if len(sys.argv) < 2:
                print("Usage: python register_webhook.py <WEBHOOK_URL>")
                sys.exit(1)

            address = sys.argv[1]
            folder_id = os.getenv("BOX_CLAIMS_FOLDER_ID")
            client = get_box_client()

            webhook = client.webhooks.create_webhook(
                target=CreateWebhookTarget(
                    id=folder_id,
                    type=CreateWebhookTargetTypeField.FOLDER,
                ),
                address=address,
                triggers=[CreateWebhookTriggers.TASK_ASSIGNMENT_UPDATED],
            )
            print(f"Webhook created: {webhook.id}")


        if __name__ == "__main__":
            main()
        ```

        Add the signature keys to `.env`:

        ```bash theme={null}
        BOX_WEBHOOK_PRIMARY_KEY=your_primary_signature_key
        BOX_WEBHOOK_SECRET_KEY=your_secondary_signature_key
        ```

        Project layout:

        ```text theme={null}
        claims-evidence-review/
        ├── .env
        ├── .venv/
        ├── app.py
        ├── automate.py
        ├── box_client.py
        ├── claims_metadata.py
        └── register_webhook.py
        ```

        Then continue to [Run and verify](#run-and-verify).
      </Accordion>
    </AccordionGroup>
  </Tab>
</Tabs>

## Run and verify

1. **Terminal 1 — start the server.** Make sure you are in the `claims-evidence-review` directory and the virtual environment is activated:

```bash theme={null}
cd ~/claims-evidence-review
source .venv/bin/activate
python3 app.py
```

You should see:

```text theme={null}
* Running on http://127.0.0.1:5000
```

Leave this terminal running.

2. **Terminal 2 — start a review.** Open a new terminal tab or window. Use file IDs from the sample PDFs in `Claims Review`, not the folder ID:

```bash theme={null}
curl -X POST http://127.0.0.1:5000/reviews \
  -H "Content-Type: application/json" \
  -d '{
    "claim_id": "CLM-1042",
    "file_ids": ["123456789", "987654321"]
  }'
```

A successful response looks like:

```json theme={null}
{
  "status": "review_started",
  "claim_id": "CLM-1042",
  "workflow": "Claims evidence review",
  "file_ids": ["123456789", "987654321"]
}
```

3. Confirm in Box:

   * Open an evidence file → **Metadata** tab shows claim `CLM-1042` and status `in_review`.
   * As the task assignee, open the approval task and confirm the claim ID appears in the message.
   * Approve or reject from the file's **Activity** sidebar panel, and confirm the matching branch runs.

`reviewStatus` stays `in_review` after this step. Continue to [Update review status after the decision](#update-review-status-after-the-decision) to patch metadata when the task completes.

## Update review status after the decision

The workflow assigns the task and runs the approved or rejected branch, but it does not write back to the file's metadata. To close the loop, add a <Link href="/guides/webhooks/v2/create-v2">V2 webhook</Link> that listens for `TASK_ASSIGNMENT.UPDATED` and patches `reviewStatus` to `approved` or `rejected`.

### Add webhook signature keys

1. In the [Developer Console](https://cloud.app.box.com/developers/console), open your app and select **Webhooks** → **Manage signature keys**. Generate a primary and secondary key.
2. Add them to `.env`:

```bash theme={null}
BOX_WEBHOOK_PRIMARY_KEY=your_primary_signature_key
BOX_WEBHOOK_SECRET_KEY=your_secondary_signature_key
```

Use the keys generated for your Box app. Generic test values cannot validate signatures from Box.

### Expose the app for webhook delivery

Box must reach your endpoint over HTTPS. For local testing, use a tunnel such as [ngrok](https://ngrok.com/):

```bash theme={null}
ngrok http 5000
```

Copy the `https://` forwarding URL. You need it for the registration step.

<Note>
  A tunnel forwards HTTPS traffic to your local Flask app at `/webhooks/task-updated`. A webhook delivered to a different public URL does not run this handler unless you forward the request or reimplement the metadata patch there.
</Note>

### Register the webhook

Run `register_webhook.py` once with the public URL of your endpoint. From the project directory with the virtual environment activated:

```bash theme={null}
cd ~/claims-evidence-review
source .venv/bin/activate
python3 register_webhook.py https://<YOUR_DOMAIN>/webhooks/task-updated
```

The script creates a V2 webhook on the claims folder for `TASK_ASSIGNMENT.UPDATED`. Register the webhook once per environment. If you create it in the Box UI instead, skip the script.

If you create the webhook in the Box UI, set **Content Type** to **Folder**, select the `Claims Review` folder, and select **Task Assignment Updated**. In this form, content type means the target item type, not the HTTP `Content-Type` header.

<Note>
  Your app must have the **Manage Webhooks** scope enabled in the Developer Console. If the scope is unavailable, contact Box Support.
</Note>

### Add the webhook handler

If you used the agent prompt, the handler is already in `app.py`. If you built by hand, add the endpoint from [step 6 of Build by hand](#build-the-service).

The handler:

1. Verifies the webhook signature using the SDK's `WebhooksManager.validate_message`.
2. Reads the decision from `source.resolution_state` and the file ID from `source.item.id`.
3. Normalizes the decision to `approved` or `rejected` and patches `reviewStatus` on the file.

If you process webhooks outside Flask, verify the Box signature, read `source.resolution_state` and `source.item.id`, normalize the decision to lowercase, and call `PUT /2.0/files/:id/metadata/enterprise/:templateKey` with a JSON Patch replace on `/reviewStatus`.

### Verify the full loop

1. Start a review with `POST /reviews`.
2. Open the file's **Metadata** tab and confirm that `reviewStatus` is `in_review`.
3. Approve or reject the task from the file's **Activity** sidebar.
4. Confirm that the webhook endpoint returns `200`.
5. Refresh the file's **Metadata** tab. `reviewStatus` now displays `approved` or `rejected`.

The gap is closed only when the metadata value changes. A delivered webhook does not update metadata unless your handler calls the Metadata API.

## Troubleshooting

<AccordionGroup>
  <Accordion title="ModuleNotFoundError: No module named '...'">
    Your virtual environment is not activated. Run `source .venv/bin/activate` from the project directory before running any `python3` commands. Each new terminal tab needs its own activation.
  </Accordion>

  <Accordion title="invalid_client: The client credentials are invalid">
    Check your `.env` file:

    * Verify `BOX_CLIENT_ID` and `BOX_CLIENT_SECRET` match the values in Developer Console > Configuration.
    * Ensure the app is authorized and its type is Client Credentials Grant.
  </Accordion>

  <Accordion title="invalid_grant: Grant credentials are invalid">
    Your app is requesting a user token, but the Developer Console is not set up for it. Confirm **App + Enterprise Access** and **Generate user access tokens**, then re-authorize the app in the Admin Console. Verify `BOX_USER_ID` is the managed user's ID (digits only, no spaces).
  </Accordion>

  <Accordion title="Empty entries list from GET /2.0/automate_workflows">
    The request succeeded, but no workflow matched for this caller. Check each of the following:

    * You are authenticated as a **managed user** who can run Automate, not as the enterprise service account. A user token that returns the workflow while the app returns `entries: []` usually means the app still uses `enterprise_id` instead of `user_id`.
    * The workflow is **published**, not saved as a draft.
    * The workflow has a **Manual Start** trigger.
    * The trigger's folder is the folder whose ID you passed as `folder_id`.
    * The managed user can access that folder.
  </Accordion>

  <Accordion title="404 Not Found on an Automate endpoint">
    Several causes produce a 404 here:

    * Box Automate is not enabled for your enterprise. Ask your admin to [enable Box Automate](https://docs.box.com/en/box-automate/enabling-box-automate).
    * Your account is on the Free Developer Plan, where these endpoints are unavailable.
    * The `box-version: 2026.0` header is missing.
    * You passed the action ID in the URL path. The path takes `entries[].workflow.id`; the body takes `entries[].id`.
  </Accordion>

  <Accordion title="400 Action not found when starting the workflow">
    The workflow and action IDs can be valid for a different actor. Hardcoding IDs from a successful user-token curl does not help if the app still authenticates as the service account. Switch the client to `BOX_USER_ID`, then confirm that `file_ids` contains at least one ID, at most 20 IDs, and that every file is within the Manual Start folder scope.
  </Accordion>

  <Accordion title="400 Bad Request when starting the workflow">
    The request reached the workflow but the payload was rejected. Confirm that `file_ids` contains at least one ID, that it holds no more than 20 IDs, and that every file is within the folder scope configured on the Manual Start trigger.
  </Accordion>

  <Accordion title="The approval task shows no claim ID">
    The workflow variable is not resolving to your metadata field. Confirm that the field keys in `tag_evidence` match the `fields[].key` values returned by `GET /2.0/metadata_templates/enterprise/:key/schema`, and that the task message references the Claim ID field from that template.
  </Accordion>

  <Accordion title="Workflow not found on folder">
    The list call succeeded, but `find_workflow_action` returned no match for `BOX_CLAIMS_WORKFLOW_NAME`. Confirm that the published workflow name matches the value in `.env` exactly. Rename the workflow in the Automate builder and activate it again, or set `BOX_CLAIMS_WORKFLOW_NAME` to the name shown in the error response. The folder display name is unrelated.
  </Accordion>

  <Accordion title="403 Forbidden">
    The app is missing a required scope. Enable **Read and write all files and folders stored in Box** for metadata and Automate calls, and **Manage Webhooks** for webhook registration. Depending on your authentication method and enterprise settings, the app may need admin authorization or reauthorization in the Admin Console before a scope change takes effect.
  </Accordion>

  <Accordion title="Webhook returns 403 invalid signature">
    Confirm that `BOX_WEBHOOK_PRIMARY_KEY` and `BOX_WEBHOOK_SECRET_KEY` in `.env` match the keys shown in the Developer Console under **Webhooks** → **Manage signature keys**. If you recently rotated a key, the in-flight delivery may still carry the old signature. See <Link href="/guides/webhooks/v2/signatures-v2">Verify webhook signatures</Link>.
  </Accordion>

  <Accordion title="reviewStatus does not update after approval">
    Open the file's **Activity** sidebar and confirm that the task has a final decision. Inspect the webhook body and confirm that `source.resolution_state` is `approved` or `rejected` and `source.item.id` is the evidence file ID. Box may send the decision in uppercase in the webhook body. Normalize it to lowercase before you compare or write metadata. If the webhook never arrives, verify that the webhook is registered and that your handler is reachable from the internet. An external processor must call the Metadata API to change `reviewStatus`.
  </Accordion>

  <Accordion title="The agent used /2.0/workflows or invented SDK methods">
    The Automate endpoints are Beta and the Box Python SDK v10 does not yet provide an Automate workflows manager. Re-paste the prompt and emphasize `client.make_request` with `box-version: 2026.0`, or select the **Build by hand** tab in [Build the service](#build-the-service) for the tested modules.
  </Accordion>
</AccordionGroup>

## Scaling to production

<AccordionGroup>
  <Accordion title="Cache the workflow and action IDs">
    The sample calls `GET /2.0/automate_workflows` on every review to resolve the workflow by name. That list call is optional in production. The IDs you need are stable for a given published workflow:

    | Value       | Source                  | Used in                                                  |
    | ----------- | ----------------------- | -------------------------------------------------------- |
    | Workflow ID | `entries[].workflow.id` | Path: `POST /2.0/automate_workflows/{workflow_id}/start` |
    | Action ID   | `entries[].id`          | Body: `workflow_action_id`                               |

    Resolve them once at startup, or store them as configuration after a successful list call:

    ```bash theme={null}
    BOX_AUTOMATE_WORKFLOW_ID=<workflow_id_from_list>
    BOX_AUTOMATE_WORKFLOW_ACTION_ID=<action_id_from_list>
    ```

    Then call start with those IDs and skip `find_workflow_action`. Keep the list lookup as a fallback if start begins failing (for example after you replace the workflow in the builder).

    <Warning>
      Caching IDs only skips the list call. Start still requires a managed user who can run Automate. Hardcoding IDs from a successful user-token curl does not work if the app authenticates as the enterprise service account. That case returns `400 Action not found`, not an empty list.
    </Warning>
  </Accordion>

  <Accordion title="Make review starts idempotent">
    A double-submitted request starts the workflow twice and can create duplicate approval tasks on the same evidence. You can use `reviewStatus` as a guard: skip the start if the value is already `in_review`. The webhook handler resets it to `approved` or `rejected` after the decision, so the field accurately reflects the current state.

    Alternatively, catch duplicates in your claims system. For example, ignore a second request with the same `claim_id` and `file_ids` while a review is still open. You can also list tasks on the file and skip start if an incomplete approval task already exists.
  </Accordion>

  <Accordion title="Secure the endpoint">
    `POST /reviews` needs authentication in production. Verify a signed request from your claims system, or place the service behind your existing gateway. Keep credentials in a secret manager rather than a `.env` file, and never expose user or app tokens to a browser client. Prefer a dedicated managed user with the least Automate and folder access your flow needs, rather than a personal admin account.
  </Accordion>

  <Accordion title="Plan for fields at start">
    Tagging metadata before the run is a workaround for a Beta limitation, and it costs one API call per file. When the start endpoint accepts fields at runtime, pass the claim ID directly in the start request and keep metadata only where you want the claim ID to persist on the file for search and reporting. Isolating the workaround in `claims_metadata.py` makes that a single-module change.
  </Accordion>

  <Accordion title="Handle more than 20 files">
    A single start request accepts 20 files at most. For larger evidence sets, split the files into batches and start one run per batch (each batch can create its own approval tasks), or restructure the workflow to trigger on the case folder rather than on individual files.
  </Accordion>
</AccordionGroup>

## Next steps

<CardGroup>
  <Card title="Box Agent Skills" href={localizeLink("/ai/agent-skills")} icon="graduation-cap" arrow="true">
    Install skills so your coding agent can scaffold more Box integrations from natural language.
  </Card>

  <Card title="Start Automate workflow" href={localizeLink("/reference/v2026.0/post-automate-workflows-id-start")} icon="code" arrow="true">
    See the full API specification for the manual start endpoint.
  </Card>
</CardGroup>

<RelatedLinks
  title="RELATED GUIDES"
  items={[
{ label: translate("Box Agent Skills"), href: "/ai/agent-skills", badge: "GUIDE" },
{ label: translate("Box Automate overview"), href: "/guides/box-automate/index", badge: "GUIDE" },
{ label: translate("Get started with Box Automate"), href: "/guides/box-automate/getting-started-box-automate", badge: "GUIDE" },
{ label: translate("Triggers, outcomes, and logic"), href: "/guides/box-automate/triggers-and-logic", badge: "GUIDE" },
{ label: translate("List Automate workflows"), href: "/reference/v2026.0/get-automate-workflows", badge: "GET" },
{ label: translate("Working with metadata"), href: "/guides/metadata/index", badge: "GUIDE" },
{ label: translate("V2 webhooks"), href: "/guides/webhooks/v2/create-v2", badge: "GUIDE" },
{ label: translate("Verify webhook signatures"), href: "/guides/webhooks/v2/signatures-v2", badge: "GUIDE" }
]}
/>
