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

# Python SDK

> Send AI-traffic pageviews and revenue events to Traceten from a Python backend with the traceten package, over HTTP that ad-blockers and privacy browsers cannot strip.

## What this lets you do

Send pageviews and revenue conversions to Traceten from your Python backend, over server-to-server HTTP that survives ad-blockers and privacy browsers. Use it where the browser snippet cannot run: server-rendered flows, background jobs, webhook handlers, and mobile or API backends.

## Before you start

* Python 3.9 or newer.
* Your Traceten `site_id`, from **Sites → Install** in the [dashboard](https://app.traceten.com/dashboard).
* Your ingest `host` URL, for example `https://ingest.traceten.com`.
* A `visitor_id` for each event. The SDK runs on your server, so there is no Traceten cookie to read. You supply the identifier from your own request context. It is either a UUID or an `h:<64-hex>` identify hash. See [supplying identifiers](https://docs.traceten.com/sdks/overview.md#you-supply-the-visitor-id).

## Install

```bash theme={"theme":{"light":"css-variables","dark":"css-variables"}}
pip install traceten
```

Zero runtime dependencies. The SDK uses the standard library only.

## Minimal example

```python theme={"theme":{"light":"css-variables","dark":"css-variables"}}
import os

import traceten

client = traceten.Client(
    site_id="ttid_7Rb4TrC1dTbnD8w3s1TS12",
    host="https://ingest.traceten.com",
    api_key=os.environ["TRACETEN_API_KEY"],
)

# An opaque visitor id from your own request context (see the privacy note under track()).
vid = "550e8400-e29b-41d4-a716-446655440000"

# A pageview -> /v1/server/events
client.page(url="https://acme.com/pricing", visitor_id=vid)

# A conversion worth $49.00 -> /v1/server/conversions  (visitor_id required)
client.track(
    "subscription_started",
    visitor_id=vid,
    value_cents=4900,     # minor units (cents)
    currency="usd",
    properties={"plan": "pro"},
)

client.close()  # flush anything still queued and stop the background thread
```

## How it behaves

`page()` and `track()` buffer the event and return immediately, never blocking on the network. The SDK sends events in the background: when the events queue reaches `flush_at`, every `flush_interval` seconds, on an explicit `flush()`, and on `close()` or interpreter exit.

Neither method raises because of a network problem. A failed delivery surfaces through your `on_error` hook. Both raise `TracetenError` synchronously on a programmer error, so you catch a mis-shaped call in development instead of a silent server-side rejection:

* `track()` called without a `visitor_id`
* an `event_name` that fails its pattern
* a `url` that is not a valid URL
* a `value_cents` that is not a non-negative integer

The client is thread-safe and can be shared across threads.

## Configuration

```python theme={"theme":{"light":"css-variables","dark":"css-variables"}}
client = traceten.Client(
    site_id="ttid_7Rb4TrC1dTbnD8w3s1TS12",
    host="https://ingest.traceten.com",
    api_key=os.environ["TRACETEN_API_KEY"],  # required; sent as Bearer auth
    flush_at=50,           # flush the events queue at this size (clamped to 1..50)
    flush_interval=5.0,    # background flush cadence, seconds
    max_retries=3,         # retries on 5xx / 429 / network error
    timeout=10.0,          # per-request timeout, seconds
    on_error=lambda err: logging.getLogger("traceten").warning("delivery failed: %s", err),
)
```

`host` must be an absolute http(s) URL. A trailing slash is stripped. There is no default host: each deployment has its own ingest URL.

`host` must use `https` unless it is a loopback address. The API key is sent as a Bearer token on every request, so a plaintext `http` host would transmit a live credential in cleartext; the SDK refuses one at construction.

`api_key` is required. See [The API key](#the-api-key) below.

## The API key

`api_key` is required. Create a key in the dashboard under Settings, API keys, or use the key shown once when you created the site.

The key must carry the **Send events** permission (`ingest:write`). New keys are created read-only by default, so tick that box when you create one, or the API will reject your calls. The key created automatically with a new site already has it. See [permissions](https://docs.traceten.com/api/authentication.md#permissions).

Keep it on your server. It is a secret: never put it in client-side code, a mobile app, or a public repository. It is not the same value as `site_id`, which is public and already embedded in your pages.

The key does two things:

1. It gets you in. The SDK posts to `{host}/v1/server/events`, `{host}/v1/server/conversions` and `{host}/v1/server/payments`, which return `401` without a valid key. The open `/v1/events` and `/v1/conversions` endpoints stay open for the browser snippet, which cannot hold a secret.
2. It gets you your own quota. Authenticated traffic is rate-limited on a bucket tied to the key, separate from the shared per-site bucket. `site_id` is public, so anyone who can read your page source can send events under it. With a key, that traffic cannot exhaust your allowance and 429 your conversion calls.

The constructor validates the key's shape and raises `TracetenError` if it is malformed. Omitting it entirely is a `TypeError`: `api_key` is a required keyword argument, so Python refuses the call. An integration that silently drops every event is worse than one that fails on its first line.

To rotate a key: create the new one, deploy it, then revoke the old one. Revocation normally takes effect at the edge within about a minute. If our
database is unreachable at that moment, an edge location that was already using
the key may keep honouring it for up to about fifteen minutes more, so that a
database blip cannot silently drop your events.

## API reference

### `Client(site_id, host, *, api_key, flush_at=50, flush_interval=5.0, max_retries=3, timeout=10.0, on_error=None)`

| Arg | Type | Required | Default | Description |
| - | - | - | - | - |
| `site_id` | `str` | yes | | Site id. 1-64 chars, `A-Z a-z 0-9 _ -`. |
| `host` | `str` | yes | | Ingest base URL. Absolute http(s). Trailing slash stripped. |
| `api_key` | `str` | yes | | Secret key, sent as `Authorization: Bearer`. See [The API key](#the-api-key). |
| `flush_at` | `int` | no | `50` | Flush the events queue at this size. Clamped to `[1, 50]`. |
| `flush_interval` | `float` | no | `5.0` | Background flush cadence in seconds. |
| `max_retries` | `int` | no | `3` | Retry attempts on 5xx / 429 / network error. |
| `timeout` | `float` | no | `10.0` | Per-request timeout in seconds. |
| `on_error` | `Callable[[Exception], None]` | no | `None` | Called when a batch is permanently dropped. |

### `page(*, url, referrer="", visitor_id=None, session_id=None, user_agent=None, event_name="pageview", timestamp=None)`

Enqueues a pageview to `POST {host}/v1/server/events`.

| Arg | Type | Required | Default | Description |
| - | - | - | - | - |
| `url` | `str` | yes | | Valid URL, up to 2048 chars. |
| `referrer` | `str` | no | `""` | Referring URL. |
| `visitor_id` | `str` | no | `None` | UUID or `h:<hash>`. |
| `session_id` | `str` | no | `None` | Your session token. |
| `user_agent` | `str` | no | `None` | The end user's User-Agent, if known. |
| `event_name` | `str` | no | `"pageview"` | Must match `^[a-z][a-z0-9_-]*$`. |
| `timestamp` | `datetime \| str` | no | now | Event time. |

```python theme={"theme":{"light":"css-variables","dark":"css-variables"}}
client.page(
    url="https://acme.com/blog/ai-search",
    referrer="https://www.perplexity.ai/",
    visitor_id=vid,
    session_id="9f2c4b1d-7e3a-42b5-8c1d-2e5f6a7b8c9d",
    user_agent=request.headers.get("user-agent"),
)
```

**Which user agent gets recorded.** The `user_agent` you pass is what Traceten
stores on the event and classifies on. Omit it and the event falls back to the
`User-Agent` header the SDK's own HTTP client sent, which describes your server
rather than the visitor. That substitution applies only to requests
authenticated with an API key. The browser snippet cannot override its own
`User-Agent` this way.

### `track(name, *, visitor_id, properties=None, value_cents=None, currency=None, session_id=None, timestamp=None)`

Enqueues a conversion to `POST {host}/v1/server/conversions`. `name` must match `^[a-z][a-z0-9_]*$` (no hyphen). `visitor_id` is required: a conversion cannot be attributed without it, and omitting it raises `TracetenError` with nothing enqueued.

| Arg | Type | Required | Default | Description |
| - | - | - | - | - |
| `visitor_id` | `str` | yes | | UUID or `h:<hash>`. |
| `properties` | `dict` | no | `{}` | Arbitrary JSON properties. |
| `value_cents` | `int` | no | `None` | Non-negative integer, minor units. Maps to `value_cents`. |
| `currency` | `str` | no | `None` | ISO-4217 code. Any case; stored uppercase. |
| `session_id` | `str` | no | `None` | Your session token. |
| `timestamp` | `datetime \| str` | no | now | Event time. |

`value_cents` is in minor units. For \$49.00, pass `4900`. The field is spelled out as `value_cents` (not `value`) to prevent the dollars-versus-cents mistake.

```python theme={"theme":{"light":"css-variables","dark":"css-variables"}}
client.track(
    "order_completed",
    visitor_id=vid,
    value_cents=12999,
    currency="usd",
    properties={"order_id": "ord_5521", "items": 3},
)
```

> [!WARNING]
>
> Keep `visitor_id` opaque: a random first-party id or a salted hash you control. Never a bare `sha256(email)` or any hash of an email or phone number (that is re-identifiable PII). The SDK enforces the shape (UUID or `h:<64-hex>`) but cannot see what you hashed. Keep `properties` to ids and enums such as `{"plan": "pro"}`. Do not put emails, names, or phone numbers in them.

### `goal(name, *, visitor_id, properties=None, value_cents=None, currency=None, session_id=None, timestamp=None)`

```python theme={"theme":{"light":"css-variables","dark":"css-variables"}}
client.goal("demo_booked", visitor_id=vid, properties={"plan": "pro"})
```

Records a [goal](https://docs.traceten.com/goals/overview.md) completion. Same arguments, same endpoint, and same payload as `track()`, with one difference: it raises `TracetenError` on the reserved names, which belong to the Stripe and Shopify integrations.

Reserved: `payment`, `free_trial`, `trial_started`, `trial_converted`, `subscription_started`, `subscription_upgraded`, `subscription_downgraded`, `subscription_renewed`, `subscription_cancel_scheduled`, `subscription_reactivated`, `subscription_ended`. `track()` still accepts them, because that is how those events are legitimately sent.

Goal property keys and values are both stored, and both are read back: `GET /v1/goals/{name}/properties` returns each key and its most common values, and the dashboard renders the same breakdown. Keep them to ids and enums. See [what happens to property keys and values](https://docs.traceten.com/goals/overview.md#what-happens-to-property-keys-and-values-exactly).

### `payment(*, transaction_id, amount, currency, provider=None, visitor_id=None, email=None, customer_id=None, renewal=None, refunded=None, is_free_trial=None, timestamp=None)`

Records a payment from any payment processor via `POST {host}/v1/server/payments`. See the [Payment API reference](https://docs.traceten.com/api/payments.md) for the full contract.

Unlike `page()` and `track()`, this one blocks until the server answers and returns what it did with the payment.

| Arg | Type | Required | Default | Description |
| - | - | - | - | - |
| `transaction_id` | `str` | yes | | The processor's id for the payment. The idempotency key. |
| `amount` | `float` | yes | | Major unit, not cents. `49.99` for \$49.99. Zero records a trial. |
| `currency` | `str` | yes | | ISO-4217 code. Any case; sent uppercase. |
| `provider` | `str` | no | `api` | Your label for the processor. Lowercase, 1 to 32 characters. |
| `visitor_id` | `str` | no | `None` | UUID or `h:<hash>`. The strongest match. |
| `email` | `str` | no | `None` | Used to find the customer's sessions. Never stored. |
| `customer_id` | `str` | no | `None` | The processor's customer identifier. |
| `renewal` | `bool` | no | `None` | A subscription renewal. Does not feed the LTV report. |
| `refunded` | `bool` | no | `None` | Refunds the payment **in full**. Never send a negative `amount`. |
| `is_free_trial` | `bool` | no | `None` | Records a trial. Implied by `amount=0`. |
| `timestamp` | `datetime \| str` | no | now | Payment time. |

`amount` is the major unit, the opposite of `track()`'s `value_cents`. For \$49.99 pass `49.99`, and for ¥5000 pass `5000`. It is the number you read off your processor.

> [!NOTE]
>
> **Partial refunds do not go through this method.** `refunded=True` takes back everything still
> outstanding on the transaction, and this SDK has no field for a smaller figure. To refund part of
> a payment, POST to [`/v1/server/payments`](https://docs.traceten.com/api/payments#refunds) with `refunded_amount`, which is
> the **total** refunded against the transaction so far.

```python theme={"theme":{"light":"css-variables","dark":"css-variables"}}
result = client.payment(
    transaction_id="pay_9fK2mQ",
    amount=49.99,
    currency="USD",
    provider="dodo",
    email="ada@example.com",
)

result["status"]  # "recorded" | "trial" | "refunded" | "duplicate"
```

Calling it again with the same `transaction_id` returns `"duplicate"` and creates nothing. That is why the SDK retries a 5xx here.

It raises `TracetenError` for an invalid field and `DeliveryError` when the payment could not be delivered after every retry. It is the only method in this SDK that raises on a delivery failure, because a dropped payment is missing revenue rather than a missing pageview.

```python theme={"theme":{"light":"css-variables","dark":"css-variables"}}
try:
    client.payment(transaction_id=charge.id, amount=49.99, currency="USD")
except traceten.DeliveryError:
    # Log and retry later. The endpoint is idempotent, so a retry is safe.
    pass
```

> [!WARNING]
>
> Do not send payments here for a processor you have also connected natively. Traceten would record
> the payment twice and overstate your revenue. See [avoiding duplicate
> payments](https://docs.traceten.com/api/payments.md#avoiding-duplicate-payments).

### `flush() -> None`

```python theme={"theme":{"light":"css-variables","dark":"css-variables"}}
client.flush()
```

Sends everything queued now, blocking until both queues drain.

### `close() -> None`

```python theme={"theme":{"light":"css-variables","dark":"css-variables"}}
client.close()
```

Flushes, stops the background thread, and marks the client closed. Idempotent. It is also registered with `atexit`, so a graceful interpreter exit flushes automatically. Call it explicitly before a hard shutdown to be safe.

## Errors

| Exception | When |
| - | - |
| `TracetenError` | Synchronous programmer error (bad args) at call time. Nothing is enqueued. |
| `DeliveryError` | A batch failed after all retries. Delivered to `on_error`. |

The SDK is otherwise silent (no `print`, no logging side effects). Wire up `on_error` for observability.

## Batching and retries

* Pageviews are sent to `/v1/server/events` in batch envelopes of up to 50 events. If you enqueue 120 pageviews, the SDK sends them as batches of 50, 50, and 20.
* Conversions are sent to `/v1/server/conversions` one body per event.
* On a 5xx, a 429, or a network or timeout error, the batch is retried with exponential backoff and full jitter, up to `max_retries` times.
* On any other 4xx the payload is malformed. The SDK drops it and calls `on_error`. It does not retry, because retrying a rejected payload forever is a self-inflicted denial of service.

## Verify it worked

1. Send one `page()` call from your backend.
2. Open **Sites → Events** in the [Traceten dashboard](https://app.traceten.com/dashboard) for that site.
3. The event appears in the live event feed within a few seconds.

If nothing arrives, wire up `on_error` to see why.

## Verify webhook signatures

If you subscribe to Traceten's outbound webhooks (AI-session and conversion events), verify every delivery before you act on it. `verify_webhook()` recomputes the `X-Traceten-Signature` HMAC in constant time (`hmac.compare_digest`), enforces the replay window, and returns the parsed event, or raises `SignatureVerificationError`.

```python theme={"theme":{"light":"css-variables","dark":"css-variables"}}
from traceten import verify_webhook, SignatureVerificationError

try:
    event = verify_webhook(
        raw_body,           # bytes (preferred) or the exact UTF-8 str of the body
        signature_header,   # X-Traceten-Signature
        timestamp_header,   # X-Traceten-Signature-Timestamp
        secret,
    )
    if event["type"] == "ai_session.classified":
        data = event["data"]  # typed as AiSessionClassifiedData
except SignatureVerificationError:
    ...  # signature or timestamp bad: reject with 401
```

### Verify the raw body, not a re-serialized one

The single most common verification bug: a framework parses the JSON, your code re-serializes it, and the bytes no longer match what Traceten signed (key order, whitespace, and unicode escaping all differ). Always pass the **raw bytes as received**, and only act on the event after verification succeeds.

* **Flask**: use `request.get_data()`, not `request.get_json()`.
* **FastAPI / Starlette**: use `await request.body()`, not a Pydantic body parameter.

```python theme={"theme":{"light":"css-variables","dark":"css-variables"}}
import os

from flask import Flask, request

from traceten import verify_webhook, SignatureVerificationError

app = Flask(__name__)
SECRET = os.environ["TRACETEN_WEBHOOK_SECRET"]


@app.post("/traceten-webhooks")
def traceten_webhooks():
    try:
        event = verify_webhook(
            request.get_data(),  # RAW bytes
            request.headers.get("X-Traceten-Signature", ""),
            request.headers.get("X-Traceten-Signature-Timestamp", ""),
            SECRET,
        )
    except SignatureVerificationError:
        return "", 401
    # Acknowledge fast; do heavy work on a queue.
    return "", 200
```

`tolerance_seconds` (default 300) sets the replay window. Pass `float("inf")` only when testing against the fixed [conformance vector](https://docs.traceten.com/webhooks/verify-signatures.md).

## Troubleshooting

**Events never appear.** Wire up `on_error`. The most common causes are a wrong `host`, a `site_id` typo, or a firewall blocking outbound HTTPS.

**`TracetenError` on `track()`.** You omitted `visitor_id`, or the event name has a hyphen. Conversion names allow only `a-z`, `0-9`, and `_`.

**`TracetenError` on `page()`.** The `url` is not a valid absolute URL, or the `event_name` does not match `^[a-z][a-z0-9_-]*$`.

**A short-lived script exits before sending.** Call `client.close()` before the process ends. The `atexit` hook covers a graceful interpreter exit, but not a hard kill.

## Next

* [Node.js SDK](https://docs.traceten.com/sdks/node.md)
* [Go SDK](https://docs.traceten.com/sdks/go.md)
* [Browser API reference](https://docs.traceten.com/sdks/browser.md) for client-side tracking
* [Stripe revenue attribution](https://docs.traceten.com/integrations/stripe.md)


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