Skip to main content
When you spend your day reading sensitive documents - tax returns, bank statements, pay stubs - you need to context-switch constantly: download a PDF, open it in a separate viewer, mark it up somewhere else, then return to the workbench to record a decision. Every shift is a chance to leak a document, lose a note, or stall an application. This tutorial keeps the entire review inside your own application:
  1. A loan workbench uploads each borrower document to Box through the secure upload API.
  2. Box converts it into a high-fidelity HTML5 rendering
  3. Your app requests a short-lived embed URL and drops it into an <iframe>.
Reviewers read and annotate the document in context. When you need a richer experience (in-context markup, a metadata and activity sidebar, or Box AI Q&A), layer on the same underlying files in Box.

Clone the working sample

Prefer to start from running code? The complete app built in this tutorial is on GitHub. Clone it, add your Box credentials, and run.

What you are building

By the end of this tutorial, you have a working integration that:
  • Uploads borrower documents to Box using the upload API, triggering automatic conversion for preview.
  • Generates a short-lived, expiring embed URL for any uploaded file.
  • Renders the document inside an <iframe> in your workbench.
  • Mints downscoped, file-scoped tokens so the browser only ever holds a least-privilege credential.
  • Upgrades the bare iframe to Box Content Preview so reviewers can annotate in context and use a richer review UI.

Prerequisites

Before you start, make sure you have the following:
  • A Box account with access to the Developer Console and admin rights to authorize a Platform App (a works; consumer/Individual accounts do not). A paid is required for the optional Box AI Q&A layer.
  • A Box with Client Credentials Grant (CCG) authentication and Application Access set to App Access Only. This makes the app’s Service Account the owner of every uploaded document.
  • Python 3.11 or higher.
  • The following scope enabled on your app:
    • Read and write all files and folders stored in Box
  • The exact origin that serves your workbench front end (for example, http://127.0.0.1:5000) added to the CORS allow-list in the . This is required for the . Content Preview fails with a 403 until the origin is allow-listed.
  • Your Box enterprise ID (found in the Developer Console under the account icon, Copy Enterprise ID).
This tutorial uses CCG authenticating as the Service Account (enterprise_id). Because the Service Account owns the files it uploads, no collaboration invites are required to preview them - a key simplification compared to integrations that operate on existing enterprise content.

How it works

Box View follows a three-stage flow:
  1. Upload. Your backend uploads the borrower document to Box over the secure upload API. Content is stored with virus scanning and 256-bit encryption.
  2. Conversion. On upload, Box automatically converts the file into HTML5-compatible assets that render crisply and responsively. Conversion happens once per file and the assets persist for as long as the file is stored.
  3. Embed. Your backend requests an expiring_embed_link for the file and returns it to the front end, which places it in an <iframe>.
This solution uses three Box Platform capabilities:
ComponentPurposeAPI
Upload APIStore the borrower’s tax return or statement in BoxPOST /2.0/files/content
Expiring embed linkGenerate a short-lived preview URL for the <iframe>GET /2.0/files/:id?fields=expiring_embed_link
Token exchange (downscope)Mint a least-privilege, file-scoped token for Box Content Preview and annotationsPOST /oauth2/token
The expiring embed link is valid for about one minute and is meant to be dropped into an <iframe> immediately after it is generated. Always generate it server-side, on demand, when the reviewer opens a document - never store it or send it ahead of time.

Step-by-step process

1

Set up the development environment

  1. Open your terminal and create a new project directory:
mkdir loan-review && cd loan-review
  1. Create and activate a Python virtual environment:
python3 -m venv .venv
source .venv/bin/activate
After activation, your terminal prompt shows (.venv) at the beginning. This confirms you are working inside the virtual environment.
Every time you open a new terminal window or tab, you must re-activate the virtual environment by running source .venv/bin/activate from the project directory. If you see ModuleNotFoundError when running commands, it usually means the venv is not activated.
  1. Install the required packages:
pip install box-sdk-gen flask python-dotenv
  1. Create a .env file to store your credentials, then add the following content. Replace the placeholder values with your actual credentials from the Box Developer Console:
BOX_CLIENT_ID=your_client_id
BOX_CLIENT_SECRET=your_client_secret
BOX_ENTERPRISE_ID=your_enterprise_id
LOAN_DOCS_FOLDER_ID=0
LOAN_DOCS_FOLDER_ID defaults to 0, the Service Account’s root folder. That is fine for getting started. For a real deployment, create a dedicated folder (for example, one folder per loan application) and use its ID so documents stay organized.
Never commit .env files to version control. Add .env to your .gitignore.
Understanding environment variables: The .env file stores sensitive values (your actual credentials). Your Python code reads these values by referencing their names using os.getenv("VARIABLE_NAME"). For example, os.getenv("BOX_CLIENT_ID") looks up the value stored next to BOX_CLIENT_ID= in your .env file. When you copy the code in the following steps, keep the quoted variable names exactly as shown. Do not replace them with your actual credentials.
2

Authenticate the Box client

Create a new file called box_client.py in your project directory. Open the file and paste the following code:
import os
from dotenv import load_dotenv
from box_sdk_gen import (
    BoxClient,
    BoxCCGAuth,
    CCGConfig,
)

load_dotenv()

def get_box_client() -> BoxClient:
    config = CCGConfig(
        client_id=os.getenv("BOX_CLIENT_ID"),
        client_secret=os.getenv("BOX_CLIENT_SECRET"),
        enterprise_id=os.getenv("BOX_ENTERPRISE_ID"),
    )
    auth = BoxCCGAuth(config=config)
    return BoxClient(auth=auth)
Client Credentials Grant is recommended for server-to-server Box View use cases where the application’s Service Account owns the content. For other authentication options, see .
3

Upload a borrower document

Create a new file called upload.py. This function uploads a document to the loan documents folder and returns the new file ID. Uploading a document automatically triggers conversion, so the file is ready to preview moments later.
import os
from dotenv import load_dotenv
from box_sdk_gen import (
    BoxClient,
    UploadFileAttributes,
    UploadFileAttributesParentField,
)

load_dotenv()

def upload_document(client: BoxClient, file_path: str) -> str:
    folder_id = os.getenv("LOAN_DOCS_FOLDER_ID", "0")
    file_name = os.path.basename(file_path)

    with open(file_path, "rb") as file_stream:
        uploaded = client.uploads.upload_file(
            UploadFileAttributes(
                name=file_name,
                parent=UploadFileAttributesParentField(id=folder_id),
            ),
            file_stream,
        )

    file_id = uploaded.entries[0].id
    print(f"Uploaded {file_name} -> file ID {file_id}")
    return file_id
For most document and image types, conversion is triggered automatically on upload. For video and 3D files, conversion is triggered on first preview instead. Either way, conversion runs only once per file and no explicit action is required from your code.
4

Generate the preview embed URL

Create a new file called preview.py. The get_embed_url function asks Box for the file’s expiring_embed_link and returns the URL your front end will place in an <iframe>.
from box_sdk_gen import BoxClient

def get_embed_url(client: BoxClient, file_id: str) -> str:
    file = client.files.get_file_by_id(
        file_id,
        fields=["expiring_embed_link"],
    )

    if not file.expiring_embed_link:
        raise RuntimeError(
            "No embed link returned. The file may still be converting, "
            "or preview is not available for this file type."
        )

    return file.expiring_embed_link.url
Request expiring_embed_link only through the fields parameter, and use it right away. The link expires after about one minute by design - this is what keeps the embed secure.
5

Mint a downscoped token for the browser

The bare iframe never needs a token, but Box Content Preview (added later) runs in the browser and does. Add a function to preview.py that exchanges the full Service Account token for a downscoped, file-scoped token. This token can only do what the scopes allow, on a single file, and is short-lived - safe to send to the browser.
from box_sdk_gen import BoxClient

PREVIEW_SCOPES = [
    "base_preview",
    "item_preview",
    "annotation_edit",
    "annotation_view_all",
    "item_download",
]

def get_downscoped_token(client: BoxClient, file_id: str) -> dict:
    token = client.auth.downscope_token(
        PREVIEW_SCOPES,
        resource=f"https://api.box.com/2.0/files/{file_id}",
    )
    return {
        "access_token": token.access_token,
        "expires_in": token.expires_in,
    }
The scopes map directly to review capabilities:
ScopeWhat it allows
base_previewPreview the file in Box Content Preview
item_previewPreview the file (expiring embed link)
annotation_editCreate, edit, and delete the reviewer’s own annotations
annotation_view_allView annotations from all reviewers on the file
item_downloadEnable text selection and highlight annotations (and download/print)
Never expose your client secret or a full access token in client-side code. Always downscope before sending a token to the browser. See .
6

Expose the backend endpoints

Create a new file called app.py. This Flask application is the backend your loan workbench calls. It exposes one endpoint to upload a document, one to fetch a fresh embed URL on demand, and one to mint a downscoped token.
import os
import tempfile

from dotenv import load_dotenv
from flask import Flask, request, jsonify, send_file

from box_client import get_box_client
from upload import upload_document
from preview import get_embed_url, get_downscoped_token

load_dotenv()
app = Flask(__name__)

@app.route("/api/documents", methods=["POST"])
def upload():
    if "file" not in request.files:
        return jsonify({"error": "no file provided"}), 400

    incoming = request.files["file"]
    with tempfile.NamedTemporaryFile(
        delete=False, suffix=f"_{incoming.filename}"
    ) as tmp:
        incoming.save(tmp.name)
        temp_path = tmp.name

    try:
        client = get_box_client()
        file_id = upload_document(client, temp_path)
    finally:
        os.remove(temp_path)

    return jsonify({"file_id": file_id}), 201

@app.route("/api/documents/<file_id>/embed", methods=["GET"])
def embed(file_id):
    client = get_box_client()
    embed_url = get_embed_url(client, file_id)
    return jsonify({"embed_url": embed_url}), 200

@app.route("/api/documents/<file_id>/token", methods=["GET"])
def token(file_id):
    client = get_box_client()
    return jsonify(get_downscoped_token(client, file_id)), 200

@app.route("/review/<file_id>")
def review_page(file_id):
    return send_file("review.html")

if __name__ == "__main__":
    app.run(port=5000, debug=True)
The /embed endpoint creates the link fresh on every request. Because the link expires in about a minute, the front end should call this endpoint at the moment the reviewer opens a document, not ahead of time.
Restart Flask whenever you add or change a route. Flask loads your code once at startup, so a newly added route (like /review/<file_id>) returns 404 Not Found until you stop the server with Ctrl+C and run python app.py again. Setting debug=True enables the auto-reloader so subsequent edits reload automatically - but the very first run after adding a route still requires a manual restart. Do not use debug=True in production.
The /review/<file_id> route serves the review page from the same origin as the backend so the page’s relative fetch("/api/...") calls reach your API. Do not open review.html by double-clicking it - opening it over the file:// protocol means the relative fetch has no server to call, the iframe src is never set, and you see only the page header. You build review.html in the next step.
At this point, your project directory should contain the following files:
loan-review/
├── .env
├── .venv/
├── app.py
├── box_client.py
├── preview.py
└── upload.py
7

Render the document in an iframe

With the backend in place, the front end is simple: fetch the embed URL, then set it as the src of an <iframe>. This is the secure, in-app review surface - the reviewer never leaves your workbench.Create a file called review.html in the project root (next to app.py, so the /review/<file_id> route can serve it). The page reads the file ID from its own URL path, so you never hardcode it.
<!DOCTYPE html>
<html lang="en-US">
  <head>
    <meta charset="utf-8" />
    <title>Loan document review</title>
    <style>
      body { margin: 0; font-family: system-ui, sans-serif; }
      header { padding: 12px 20px; background: #f5f6f8; border-bottom: 1px solid #e0e0e0; }
      iframe { width: 100%; height: calc(100vh - 50px); border: 0; }
    </style>
  </head>
  <body>
    <header><strong>Loan application 4815</strong> &middot; Borrower tax return</header>
    <iframe id="doc" title="Borrower document"></iframe>

    <script>
      // The file ID is the last segment of the URL, e.g. /review/1234567890
      const fileId = window.location.pathname.split("/").pop();

      async function loadPreview() {
        const res = await fetch(`/api/documents/${fileId}/embed`);
        const { embed_url } = await res.json();
        document.getElementById("doc").src = embed_url;
      }

      loadPreview();
    </script>
  </body>
</html>
Always reach this page through the backend at http://127.0.0.1:5000/review/<file_id> - never by double-clicking review.html. A double-clicked file opens over file://, where the relative fetch("/api/...") has no server to reach. The iframe src is then never set and you see only the header. The page and the API must share an origin.
In production, the workbench app and the Box-facing backend typically live behind the same gateway (same origin), or you proxy /api/* to the backend, so these relative fetch calls resolve.
8

Test the upload and embed flow

You can exercise the whole pipeline from the command line before wiring it into a UI.
Make sure you are in the loan-review directory and the virtual environment is activated before running any commands:
cd ~/loan-review
source .venv/bin/activate
1. Start the backend:
python app.py
You should see Running on http://127.0.0.1:5000. Leave this terminal running.2. Upload a document. In a second terminal, upload a sample tax return or statement (any PDF works):
curl -X POST http://127.0.0.1:5000/api/documents \
  -F "file=@sample-tax-return.pdf"
You should get back a file ID:
{ "file_id": "1234567890" }
3. Fetch an embed URL for that file ID:
curl http://127.0.0.1:5000/api/documents/1234567890/embed
{ "embed_url": "https://app.box.com/preview/expiring_embed/gvoct6FE!YT_X1Lau..." }
4. View it in the iframe. With the backend still running, open the review page through the server, using your file ID as the last path segment:
http://127.0.0.1:5000/review/1234567890
The document renders inside the iframe. If instead you see only the Loan application 4815 header, you most likely opened review.html by double-clicking it (the file:// protocol) - open it through the URL above instead. As a separate sanity check, pasting the embed URL from step 3 directly into the browser within a minute also renders the document, proving conversion succeeded.

Troubleshooting

Your virtual environment is not activated. Run source .venv/bin/activate from the project directory before running any python commands. Each new terminal tab needs its own activation.
Check your .env file:
  • Verify BOX_CLIENT_ID and BOX_CLIENT_SECRET match the values in Developer Console > your app > Configuration.
  • Confirm BOX_ENTERPRISE_ID is your enterprise ID (Developer Console > account icon > Copy Enterprise ID).
  • Ensure the app type is Client Credentials Grant and the app has been authorized.
The app has not been authorized by an admin yet. In the Developer Console, open the Authorization tab, click Review and Submit, and have your Box admin approve it in the Admin Console. See .
Flask does not have the /review/<file_id> route registered. This almost always means you added the route but did not restart the server - Flask loads your code once at startup. Stop the server with Ctrl+C and run python app.py again. Also confirm send_file is imported (from flask import Flask, request, jsonify, send_file) and that the route exists in app.py. Running with debug=True enables auto-reload for future edits.
You opened review.html directly (over the file:// protocol) instead of through the backend. The page’s relative fetch("/api/documents/.../embed") call only works when the page is served from the same origin as the API. Start the backend and open http://127.0.0.1:5000/review/<file_id> instead of double-clicking the file. Open your browser’s developer console to confirm - you will see the failed fetch request.
The embed link has expired. Embed links last about one minute. Generate the link immediately before setting the iframe src, and request a new one each time the reviewer opens the document.
This is almost always a CORS allow-list issue. The iframe review page loads a Box-hosted URL and never calls the Box API from your origin, so it works without CORS. Box Content Preview, however, calls api.box.com directly from the browser - in your developer console you will see repeated GET https://api.box.com/2.0/files/<id>?fields=... requests returning 403, thrown from preview.js.Fixes, in order:
  • Add the exact origin you open the page from to the CORS allow-list (Developer Console > your app > Configuration > CORS Domains), including scheme and port - for example http://127.0.0.1:5000. Remember that 127.0.0.1 and localhost are different origins; add whichever you use. Save and wait a minute or two, then restart and reload.
  • If it still 403s, the downscoped token lacks permissions: confirm the Read and write all files and folders scope is enabled and that you re-authorized the app in the Admin Console afterward.
  • Confirm the file ID in the /annotate/<file_id> URL matches the file the token was minted for - the token is downscoped to a single file, so a mismatched ID also returns 403.

Add in-context annotations with Box Content Preview

The iframe embed is perfect for fast, read-only review. To let reviewers mark up documents in context - highlight a suspicious figure on a tax return, drop a point comment on a missing signature - render the same file with the UI Element instead of the bare iframe. Content Preview powers preview in the Box web app itself and runs entirely client-side, so annotations stay inside your workbench. Content Preview needs a token in the browser. You already built the /token endpoint that returns a downscoped, file-scoped token with annotation scopes, so the front end never sees a full-privilege credential.
You must add your serving origin to the CORS allow-list before this step will work. Box Content Preview makes API calls directly from the browser to api.box.com. Box rejects those calls with a 403 (Forbidden) unless the page’s origin is on your app’s CORS allow-list (Developer Console > your app > Configuration > CORS Domains).Add the exact origin you open the page from, including the scheme and port - for example http://127.0.0.1:5000. Note that http://127.0.0.1:5000 and http://localhost:5000 are different origins: allow-list whichever you actually browse to (or add both). Changes can take a minute or two to take effect.
Create a file called annotate.html in the project root, then add a route in app.py to serve it from the same origin as the API - just like the iframe page:
@app.route("/annotate/<file_id>")
def annotate_page(file_id):
    return send_file("annotate.html")
<!DOCTYPE html>
<html lang="en-US">
  <head>
    <meta charset="utf-8" />
    <title>Loan document review</title>
    <link
      rel="stylesheet"
      href="https://cdn01.boxcdn.net/platform/preview/3.0.0/en-US/preview.css"
    />
    <script src="https://cdn01.boxcdn.net/platform/preview/3.0.0/en-US/preview.js"></script>
    <style>
      .preview-container { height: 100vh; width: 100%; }
    </style>
  </head>
  <body>
    <div class="preview-container"></div>

    <script>
      // The file ID is the last segment of the URL, e.g. /annotate/1234567890
      const fileId = window.location.pathname.split("/").pop();

      async function loadPreview() {
        const res = await fetch(`/api/documents/${fileId}/token`);
        const { access_token } = await res.json();

        const preview = new Box.Preview();
        preview.show(fileId, access_token, {
          container: ".preview-container",
          showAnnotations: true,
        });
      }

      loadPreview();
    </script>
  </body>
</html>
Open it the same way you opened the iframe page - through the backend, at http://127.0.0.1:5000/annotate/<file_id>, not by double-clicking the file. When showAnnotations is true and the token carries the annotation scopes, reviewers see annotation controls in the preview header. Annotations are stored against the file in Box, so a second reviewer opening the same document sees the first reviewer’s notes (granted annotation_view_all).
Box Annotations support highlight comment, highlight only, draw, and point annotation types. Point annotations work on both documents and images; highlight and draw annotations are document-only. Highlight annotations also require the item_download scope so the text layer is available. See the for the full matrix and for enabling real-time V4 annotations.
If the preview still returns a 403 after you have allow-listed the origin, the downscoped token is missing permissions. Confirm the Read and write all files and folders scope is enabled on your app, and that you re-authorized the app in the Admin Console after enabling it - CCG scope changes do not take effect until the app is re-approved. See the guide.

Layer a richer review UI

Once you are running Box Content Preview, you can progressively enrich the reviewer experience on the very same files - no re-upload, no second copy of the document.
Use the Content Preview element’s sidebar to show file details, versions, and an activity feed alongside the document, so reviewers can see the document’s history and discuss it without leaving the page. Enable annotations in the activity feed and the document view together for a connected review workflow. See .
Box AI for UI Elements adds document Q&A and summaries directly in the Content Preview header. A reviewer can ask “What is the borrower’s reported annual income?” and get a cited answer grounded in the document. Pass hasHeader: true and contentAnswersProps to preview.show(). See .
A loan application is rarely one file. Pass a collection of file IDs to preview.show() so reviewers can page through the tax return, W-2, and bank statements with navigation arrows inside one previewer.
preview.show(fileId, access_token, {
  container: ".preview-container",
  collection: ["1234567890", "1234567891", "1234567892"],
  showAnnotations: true,
});
Mint the downscoped token with a resource and scopes that cover each file in the collection.
Match the token’s scopes to the reviewer’s role. A junior reviewer might get base_preview + annotation_view_self + annotation_edit (sees only their own notes), while a senior underwriter gets annotation_view_all. After a decision is locked, drop the edit scopes to make the document effectively read-only. See .

Scaling to production

Instead of uploading everything to the Service Account root, create a folder per loan application and pass its ID as the upload parent. This keeps documents grouped, makes cleanup simple, and lets you apply at the folder level for compliance.
The client secret, enterprise ID, and full access token must never reach the browser. Store them in environment variables or a secrets manager, keep Application Access set to App Access Only, and restrict the CORS allow-list to the exact domains that run Box Content Preview. See .
Use to track previews, downloads, and annotation activity across every loan document. This gives compliance teams an audit trail of who reviewed what, and when - without instrumenting your own app.

Next steps

Invoice intake automation

Automate accounts payable with Box AI Extract and metadata.

Box View overview

Learn how upload, conversion, and embed fit together.
Last modified on July 7, 2026