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

# Trust Center Analytics in Hex: Which Customers Read Which Documents

> Use the Wolfia API to load trust center document views and downloads into Hex, join them to your CRM accounts, and refresh the dashboard on a schedule.

## Why teams build this

Security and GRC leaders are increasingly asked to show that the trust center is doing commercial work. The question from leadership is usually some version of "which accounts are reviewing us, and what are they reading?"

Answering it in a spreadsheet gets stale within a week. Answering it in Hex lets you join trust center activity to the CRM and pipeline data you already have there, so document engagement sits next to deal stage and account owner.

## What one row looks like

The dataset this guide produces has one row per interaction:

| account\_domain | user\_email                           | document\_title          | event\_type          | occurred\_at        |
| --------------- | ------------------------------------- | ------------------------ | -------------------- | ------------------- |
| acme.com        | [dana@acme.com](mailto:dana@acme.com) | SOC 2 Type II Report     | DOCUMENT\_DOWNLOADED | 2026-07-14 09:12:03 |
| acme.com        | [dana@acme.com](mailto:dana@acme.com) | Penetration Test Summary | DOCUMENT\_VIEWED     | 2026-07-14 09:15:41 |
| globex.com      |                                       | SOC 2 Type II Report     | DOCUMENT\_VIEWED     | 2026-07-15 16:02:19 |
| unattributed    |                                       | Penetration Test Summary | DOCUMENT\_VIEWED     | 2026-07-15 16:44:07 |

No cell is ever null. Rows carry `unattributed` and an empty email when a visit has not been tied to a company or a person, and two boolean columns let you filter to the level of attribution you want. See [how much of the activity is identified](#how-much-of-the-activity-is-identified).

`event_type` currently returns one of `DOCUMENT_VIEWED`, `DOCUMENT_DOWNLOADED`, or `LINK_DOCUMENT_OPENED`. Treat it as a passthrough column so new activity types flow into your dashboard without a code change.

## Before you start

| Requirement  | Detail                                                                                                  |
| ------------ | ------------------------------------------------------------------------------------------------------- |
| Role         | The API key owner needs the **Admin** role. The per-document endpoint used here is admin only.          |
| Trust center | Live and receiving visitors. Interactions are only recorded from the point your trust center goes live. |
| Hex          | Any project where you can add a secret and run a Python cell.                                           |

<Note>
  Bind the key to a service account. A scheduled Hex refresh then keeps working when the person who created the key changes roles or leaves the company. See [service accounts](/how-to/service-accounts).
</Note>

## Step 1: Create an API key

<Steps>
  <Step title="Create the service account first">
    Go to [Settings → Service accounts](https://wolfia.com/settings/service-accounts) and create one named something like `Hex reporting`. Set its **role** to **Admin**, because the per-document endpoint this guide uses is admin only and the role selector starts on **User**.

    Do this before opening API settings. The **Owner** dropdown on the key dialog lists the service accounts that already exist, so it offers only **Yourself** until one is there to pick. Full walkthrough: [service accounts](/how-to/service-accounts).
  </Step>

  <Step title="Open API settings">
    Go to [Settings → API](https://wolfia.com/settings/api). This page is visible to admins.
  </Step>

  <Step title="Create the key">
    Click **Create API key**. Give it a **Name** such as `Hex trust center reporting`, set **Owner** to your service account, and pick an **Expiration**.
  </Step>

  <Step title="Copy it once">
    The key is shown a single time and starts with `wolfia-api-`. Copy it before closing the dialog.
  </Step>
</Steps>

## Step 2: Store the key as a Hex secret

Open the **Variables** sidebar in your Hex project. In the **Secrets** section, click **+Add**, choose **Create project secret**, and name it `WOLFIA_API_KEY`.

Hex stores secrets in an encrypted vault and renders them as `[SECRET VALUE]` if anything tries to display them, so the key stays out of your notebook source and out of any published app. A secret is referenced in a Python cell by its bare name, which is why the sample below uses `WOLFIA_API_KEY` directly with no lookup around it.

<Warning>
  Use a **secret** for the API key. Hex environment variables are read through `os.environ` and are stored without encryption or redaction, which makes them unsuitable for a credential.
</Warning>

## Step 3: Build the interactions dataframe

This cell walks every document in your trust center, pulls the interaction history for each one, and returns a tidy dataframe.

It requests one calendar month at a time. A single request returns at most 100 activity events, so a busy document in a wide date window would silently lose its older events. Monthly windows keep each request comfortably under that ceiling.

```python theme={null}
import time
from datetime import datetime, timedelta, timezone

import pandas as pd
import requests

# WOLFIA_API_KEY is provided by Hex from the project secret of the same name.
BASE_URL = "https://api.wolfia.com/v1"
HEADERS = {"X-API-Key": WOLFIA_API_KEY}
REQUEST_PAUSE_SECONDS = 2
MAXIMUM_EVENTS_PER_REQUEST = 100

# Anonymous visits are real engagement, so they are labelled rather than dropped.
UNATTRIBUTED_DOMAIN = "unattributed"
UNATTRIBUTED_LABEL = "Unidentified visitor"


def to_microseconds(moment: datetime) -> int:
    """Wolfia timestamps are microseconds since the Unix epoch."""
    return int(moment.timestamp() * 1_000_000)


def month_windows(first_month: datetime, last_month: datetime):
    """Yield (start, end) pairs covering one calendar month each."""
    cursor = first_month
    while cursor < last_month:
        following_month = (cursor + timedelta(days=32)).replace(day=1)
        yield cursor, min(following_month, last_month)
        cursor = following_month


def list_documents() -> list[dict]:
    """Every document in the trust center, including ones since removed."""
    documents, page = [], 1
    while True:
        response = requests.get(
            f"{BASE_URL}/trustportal/analytics/documents",
            headers=HEADERS,
            params={"page": page, "page_size": 100},
            timeout=30,
        )
        response.raise_for_status()
        body = response.json()
        documents.extend(body["documents"])
        if page >= body["total_pages"]:
            return documents
        page += 1
        time.sleep(REQUEST_PAUSE_SECONDS)


def fetch_interactions(document_id: str, window_start: datetime, window_end: datetime) -> list[dict]:
    response = requests.get(
        f"{BASE_URL}/trustportal/analytics/documents/{document_id}",
        headers=HEADERS,
        params={
            "start_date": to_microseconds(window_start),
            "end_date": to_microseconds(window_end),
            "activity_limit": MAXIMUM_EVENTS_PER_REQUEST,
        },
        timeout=30,
    )
    response.raise_for_status()
    detail = response.json()
    return [
        {
            "event_id": event["event_id"],
            "account_domain": event["account_domain"] or UNATTRIBUTED_DOMAIN,
            "account_name": event["account_name"] or UNATTRIBUTED_LABEL,
            "user_email": event.get("user_email") or "",
            "user_name": event.get("user_name") or "",
            "document_title": detail["document_title"],
            "event_type": event["event_type"],
            "occurred_at": datetime.fromtimestamp(
                event["occurred_at"] / 1_000_000, tz=timezone.utc
            ),
            "has_known_account": bool(event["account_domain"]),
            "has_known_person": bool(event.get("user_email")),
        }
        for event in detail["recent_activity"]
    ]


def build_interactions_frame(first_month: datetime, last_month: datetime) -> pd.DataFrame:
    documents = list_documents()
    rows = []
    for window_start, window_end in month_windows(first_month, last_month):
        for document in documents:
            rows.extend(fetch_interactions(document["document_id"], window_start, window_end))
            time.sleep(REQUEST_PAUSE_SECONDS)
    return pd.DataFrame(rows).drop_duplicates(subset="event_id")


trust_center_interactions = build_interactions_frame(
    first_month=datetime(2026, 1, 1, tzinfo=timezone.utc),
    last_month=datetime(2026, 8, 1, tzinfo=timezone.utc),
)
```

Assign the result to a named dataframe and Hex will make it available to downstream SQL and chart cells.

## How much of the activity is identified

A trust center is a public page. Someone can read your SOC 2 before they ever tell you who they are, so every interaction falls into one of three levels of identification. The sample code labels all three so your dashboard has no blank cells, and it sets two boolean columns so you can filter deliberately.

| Level         | `account_domain`   | `user_email`   | What it means                                                                           |
| ------------- | ------------------ | -------------- | --------------------------------------------------------------------------------------- |
| Person known  | the company domain | the individual | Someone verified their identity, so the activity is tied to both a company and a person |
| Company known | the company domain | empty string   | The visit is attributed to a company, without an identified individual                  |
| Unidentified  | `unattributed`     | empty string   | An anonymous visit that has not been tied to anyone                                     |

Two things follow from how this works, and both are useful:

* **An email never appears without a domain.** Individual identification comes from an access request, and an access request always belongs to an account. So `account_domain` is always at least as complete as `user_email`, and it is the right column to group by for account level reporting.
* **Unidentified visits are real engagement.** Dropping them understates how much your trust center is being read, which is usually the opposite of the point. Keep them in the totals and filter only when you specifically need account level attribution:

```python theme={null}
by_account = trust_center_interactions[trust_center_interactions["has_known_account"]]
by_person = trust_center_interactions[trust_center_interactions["has_known_person"]]
```

The proportion of each level varies by trust center, by how much traffic arrives from public search, and by how much of your document set sits behind an access request.

## Choosing your join keys

**Use `document_title` as the document key.** Documents are grouped by title, so re-uploading a refreshed SOC 2 keeps its history in one place. The `document_id` you pass to the API points at the current representative version of a title and can change after a re-upload, which makes it a poor primary key for a saved dashboard.

One caveat: grouping is by exact title, so `SOC 2 Type 2 Report` and `Acme SOC 2 Type II Report` count as two documents. If you have renamed a document over time, map the variants to one canonical name in your dashboard.

**Use `account_domain` as the customer key.** It is the stable identifier for a visiting company and joins cleanly to the website or domain field on your CRM accounts.

## Keeping the dataset fresh

<AccordionGroup>
  <Accordion title="Re-pull a trailing window on every run">
    A visitor can browse before they finish verifying their identity. When they do verify, their earlier activity is attributed to them and to their account, so a row that was anonymous an hour ago can gain an `account_domain` and a `user_email`.

    Set each scheduled run to re-pull the last 30 days and let `event_id` de-duplicate. Rows that gained attribution get corrected, and nothing is double counted.
  </Accordion>

  <Accordion title="De-duplicate on event_id">
    `event_id` is unique and stable per interaction. It is the right key for an upsert into a warehouse table and for `drop_duplicates` in a notebook.
  </Accordion>

  <Accordion title="Backfill once, then run incrementally">
    Run the full historical range once and write it to a table. After that, schedule a run over a trailing window only. A full backfill across many months and many documents makes a lot of requests, so keep it to a one-off.
  </Accordion>

  <Accordion title="Removed documents stay reportable">
    Documents you have taken down still appear in the document list, flagged with `is_deleted`. Their historical interactions remain available so a trend line does not develop a hole when you retire a report.
  </Accordion>
</AccordionGroup>

## Limits to design around

| Limit              | Value                                                                   | How to handle it                                                                                                     |
| ------------------ | ----------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- |
| Events per request | 100 (`activity_limit`)                                                  | Narrow the date window. Monthly windows are a safe default.                                                          |
| Rate limit         | 1,000 requests per hour per organization, with a burst of 30 per minute | Keep the pause between requests in the sample, and backfill outside business hours.                                  |
| Date parameters    | Microseconds since the Unix epoch                                       | Multiply Unix seconds by 1,000,000. `start_date` must be earlier than `end_date`, otherwise the request returns 400. |
| Role               | Admin for the per-document endpoint                                     | Check the key owner's role if you get a 403.                                                                         |

## Troubleshooting

<AccordionGroup>
  <Accordion title="401 Unauthorized">
    Confirm the header is `X-API-Key` and the value begins with `wolfia-api-`. A key that has been deactivated, deleted, or has passed its expiration date also returns 401. Check its status on [Settings → API](https://wolfia.com/settings/api).
  </Accordion>

  <Accordion title="403 Forbidden">
    The per-document endpoint requires the Admin role. If the key is bound to a service account, raise that service account to Admin or issue the key under an admin owner.
  </Accordion>

  <Accordion title="429 Too Many Requests">
    You have exceeded the hourly or burst rate limit. Increase `REQUEST_PAUSE_SECONDS`, and split a large backfill across several runs.
  </Accordion>

  <Accordion title="The dataframe is empty">
    Check the date window first. `start_date` and `end_date` are microseconds, and passing seconds or milliseconds produces a window that matches nothing. Then confirm your trust center was live during the period you asked for.
  </Accordion>

  <Accordion title="Many rows have an empty user_email">
    This is expected on a public trust center rather than a fault in the extract. A visitor can read a public document without identifying themselves, and a row gains an email only once someone verifies their identity, so re-pulling a trailing window fills in more of them over time.

    Group by `account_domain` for account level reporting, or filter on `has_known_person` when you specifically need named individuals. The three levels of identification are explained in [how much of the activity is identified](#how-much-of-the-activity-is-identified).
  </Accordion>

  <Accordion title="Rows show account_domain as unattributed">
    Same cause, one level up. The visit was not tied to any company, usually because the reader arrived on a public page and never requested access. The sample code labels these `unattributed` so nothing is silently dropped from your totals. Filter on `has_known_account` to exclude them from account level views.
  </Accordion>
</AccordionGroup>

## Adapting this to other tools

Nothing here is specific to Hex beyond where the credential lives. The same requests work from any scheduler or notebook that can hold a secret and make HTTPS calls, including Databricks, Snowflake external functions, Airflow, and dbt Python models.

Replace the bare `WOLFIA_API_KEY` reference with your platform's own secret lookup, which on most of them is `os.environ["WOLFIA_API_KEY"]`, and keep the rest of the code as it is.

## Next steps

<CardGroup cols={2}>
  <Card title="Trust analytics API" icon="chart-mixed" href="/how-to/api-trust-portal-analytics">
    The full endpoint reference, including revenue attribution, active accounts, and access requests.
  </Card>

  <Card title="API overview" icon="code" href="/how-to/api-overview">
    Authentication, rate limits, status codes, and every endpoint area.
  </Card>

  <Card title="Service accounts" icon="robot" href="/how-to/service-accounts">
    Bind API keys to a non-human identity so scheduled jobs survive team changes.
  </Card>

  <Card title="Trust Center" icon="shield-halved" href="/how-to/trust-center">
    Set up documents, branding, and access controls on the trust center itself.
  </Card>
</CardGroup>
