Intempt Docs
Developer DocsSDK

Python SDK

Send events, identity, consent and commerce from your Python server, and read recommendations on the request path.

Python SDK

Server-side client for Python. Data in, decisions out.

It holds no per-user state: every call takes its identifier explicitly, so one client instance is shared across every request, user and thread.

Requirements

Python3.9 or newer
Dependenciesnone
Thread safetyone client is safe to share across threads

Installation

Not on PyPI yet. pip install intempt does not resolve — there is no such package. The SDK is in preview and installs from source until it is published.

pip install "git+https://github.com/intempt/intempt-python@feature/v1-mixpanel-core"

The repository is public. feature/v1-mixpanel-core is its default branch while the SDK is pre-release; this page will move to pip install intempt once the package is published.

Setup

valuewhere it comes fromrequired
orgyour organization name in the console URLyes
projectyour project name in the console URLyes
api_keySettings → API keys, a public key in <prefix>.<secret> formyes
source_idSources → your source, a 19-digit idrecommended
export INTEMPT_API_KEY='your-prefix.your-secret'

Keep source_id a string. It is a 19-digit number, and passing it through int() on a 32-bit build or through any float path loses the last digits, which addresses a different source with no error.

Initialization

import os
from intempt import Intempt

intempt = Intempt(
    org="my-org",
    project="my-project",
    api_key=os.environ["INTEMPT_API_KEY"],
    source_id="684508596718616576",
)

Create it once for the process. Nothing is sent at init.

Capture

track

intempt.track(
    "purchase",
    user_id="user@example.com",
    properties={"total": 99.99, "currency": "USD"},
)
optiontypenotes
user_idstryour identifier for a person
account_idstryour identifier for a company
propertiesdictevent properties
user_attributesdicttraits to set on the person
account_attributesdicttraits to set on the account
timestampdatetime | intepoch milliseconds, defaults to now

At least one of user_id or account_id is required. Every method raises on failure — nothing is swallowed.

track_batch

intempt.track_batch([
    {"event": "page_view", "user_id": "u1", "properties": {"path": "/pricing"}},
    {"event": "signup", "user_id": "u2"},
])

Chunked so one oversized call cannot become one oversized request. An error names the offending index: track_batch[1]: event name is required.

Commerce

intempt.ecommerce.product_viewed(user_id="u1", product_id="sku-1")
intempt.ecommerce.added_to_cart(user_id="u1", product_id="sku-1", quantity=2)
intempt.ecommerce.ordered(
    user_id="u1",
    products=[{"product_id": "sku-1", "quantity": 2}, {"product_id": "sku-2"}],
)

Identity and lifecycle

intempt.identify(user_id="u1", traits={"plan": "pro"})
intempt.group(user_id="u1", account_id="acme", attributes={"tier": "ent"})
intempt.alias(user_id="new-id", previous_user_id="old-id")

There is no reset(), and that is deliberate. One client is shared across every request, and each call carries its own identifier, so there is nothing to reset. If you are looking for it, you are probably holding one client per user — hold one per process instead.

profile_id and master_id are not part of this SDK. profile_id is device-minted by the browser SDK, so a server that invents one creates an orphan profile; master_id is assigned after identity resolution and cannot be read from a server.

Opt in / out

intempt.opt_out()      # suppresses track, batch, commerce and consent
intempt.opt_in()
intempt.is_opted_in()  # bool

The gate is applied when events are sent, so anything buffered before opt_out() is discarded rather than transmitted by a later flush.

intempt.consent.grant(user_id="u1", category="marketing")
intempt.consent.revoke(user_id="u1", reason="user requested deletion")

Delivery

By default each call sends one request and returns when the server responds. Nothing is buffered, which makes the default safe in Lambda and other short-lived processes.

For a long-lived server:

from intempt import BatchOptions

intempt = Intempt(
    org="my-org",
    project="my-project",
    api_key=os.environ["INTEMPT_API_KEY"],
    batch=BatchOptions(size=50, flush_ms=5_000, max_queue=10_000),
)

intempt.flush()   # send what is buffered now
intempt.close()   # drain and release, at shutdown

The client is also a context manager, which closes it for you:

with Intempt(org="my-org", project="my-project", api_key=key) as intempt:
    intempt.track("purchase", user_id="u1")

close() drains for at most 30 seconds, then stops retrying and logs how many events it gave up on. flush() is not bounded.

Retry policy

responsebehaviour
413, batch > 1halve the batch size, retry
413, batch = 1drop the event, log it, return the width to full
429honour Retry-After, else exponential backoff
5xx, 408, timeoutexponential backoff, floored at 100ms, capped at 10 minutes
other 4xxdrop the batch, log the status and body
5 consecutive failuresstop batching and report how many events are stranded

Delivery is at-least-once, not exactly-once. Ingestion has no idempotency key, so a retry after a lost response duplicates rows. Leave batch unset if you would rather a failure surface to your code than be retried.

Personalization

feed = intempt.recommend(
    user_id="user@example.com",
    feed_id="5292",
    fields=["id", "title"],
    limit=5,
)

Pass exactly one of user_id or account_id — the feeds API resolves a single entity, so passing both is an error.

Experiments and personalizations are deliberately absent: they resolve a web experience against a page, and a server has no page to modify.

Property types

Python typesent as
str, int, float, boolas-is
datetimeepoch milliseconds
setlist
Nonedropped, never reaches the wire
dict, listas-is

A naive datetime is treated as UTC, not local time — local time would make the same code produce different events on a laptop and a server.

Timestamps

timestamp is a backfill mechanism between 2010 and 2040.

your timestampwhat happens
before 2010-01-01request rejected, error names the threshold
2010 to 2040stored as given
after 2040-01-18silently replaced with the server's clock

Sending seconds where milliseconds were meant lands past 2040, is quietly rewritten to now, and the event looks like it just happened — with no error.

Configuration reference

Every option Intempt(...) accepts. Only the first three are required.

optiontypedefaultwhat it does
orgstryour organization name, from the console URL
projectstryour project name, from the console URL
api_keystra public key, <prefix>.<secret>
source_idstrNonethe source events are attributed to. Keep it a string
hoststrapi.intempt.comaccepts host or host:port
schemestrhttpshttp is accepted for local testing only
pathstr""prefix for every request path, for a reverse proxy
timeoutfloat10.0seconds, applied to connect and to read
keep_aliveboolTruereuse one connection. Fixed at construction
debugboolFalselog the method and path of every request. Never the body
max_request_eventsint50hard ceiling on events per request, independent of batch.size
max_concurrent_requestsint1in-flight requests. 1 keeps events in order
batchBatchOptionsNoneoff by default; see Delivery
loggerloggerlogging.getLogger("intempt")anything with debug/info/warning/error

BatchOptions:

optiontypedefaultwhat it does
sizeint50events per request, capped by max_request_events
flush_msint5000milliseconds before an incomplete batch is sent
max_queueint10000events held in memory. Beyond this, new events are dropped and logged
flush_on_exitboolTruedrain on normal interpreter exit

set_config() changes host, scheme, path, timeout, debug, max_request_events and logger on a live client. The rest are fixed at construction because the connection is built once — passing one to set_config() raises rather than being ignored.

Errors and troubleshooting

from intempt import IntemptApiError, IntemptConfigError

try:
    intempt.track("purchase", user_id="u1")
except IntemptApiError as error:
    print(error.status, error.body, error.retryable)
except IntemptConfigError as error:
    print("bad arguments:", error)
exceptionwhen
IntemptConfigErrorbad configuration or arguments — never retried
IntemptApiErrorthe API answered, or the transport failed
IntemptErrorbase class, catches both

IntemptApiError.status is None for a transport failure or timeout, which is why retryable treats that case as retryable — nothing came back to say the request was rejected on its merits.

Verifying an event arrived

  1. Send one track call with a user_id you can recognise.
  2. Open Sources → your source → Live events in the console.
  3. The event appears with the name you sent.
symptomcause
every call raises with 401wrong api_key, or the key belongs to another project
404 on every callwrong org or project — check the console URL
events land in the wrong sourcesource_id lost precision; keep it a string
commerce accepted but no product showsingestion returns 201 for unknown ids
nothing arrives and nothing errorsyou called opt_out(), or batching is on and you never flushed

Ingestion answers 201 for unknown accounts and products, so a made-up product_id returns success and proves nothing. Test with an id that exists in your catalog.

Frequently asked

What happens to an event that is too large? The platform answers 413. With more than one event in the batch the SDK halves the batch and retries, so an oversized batch still gets through as smaller ones. A single event that is still refused is dropped and logged — it cannot be made smaller. After three consecutive drops the SDK says so once, rather than logging every one.

Is event order preserved? Within one client, yes. The queue is FIFO and one request is in flight at a time, so events leave in the order you recorded them. Order across separate clients or processes is not guaranteed, and the platform orders by your timestamp rather than by arrival.

Is there an EU or regional endpoint? No. There is one endpoint, api.intempt.com, and every project is served from it. The host option exists to point at a reverse proxy or a test server, not at a region. If data residency matters to your deployment, raise it before you integrate rather than after.

What happens if the process dies with events buffered? They are lost. The buffer is in memory and nothing is written to disk. close() drains it first, and flush_on_exit covers a normal exit, but neither survives SIGKILL or a crash. Leave batching off where the process is short-lived — that is the default.

How do I send historical events? Pass timestamp explicitly. It is a genuine backfill mechanism between 2010 and 2040, and the event store keeps your value while recording arrival separately. See Timestamps for the two ways this goes wrong.

Can I use one client for everything? The client is thread-safe. Share one across every request and thread — the client holds no per-user state, and every call carries its own identifier. That is why there is no reset().

Does opt_out() delete data already sent? No. It stops this client sending anything further, and it is applied at send time, so anything already buffered is discarded rather than transmitted. Deleting stored data is a separate request — see the consent API and your DSAR process.

Why did my event not appear, with no error? Four causes, in the order they happen: you called opt_out(); batching is on and you never flushed; the sourceId lost precision and the event went to a different source; or the timestamp was past 2040 and was rewritten to now, so the event is there but not where you were looking.

On this page