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
| Python | 3.9 or newer |
| Dependencies | none |
| Thread safety | one 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
| value | where it comes from | required |
|---|---|---|
org | your organization name in the console URL | yes |
project | your project name in the console URL | yes |
api_key | Settings → API keys, a public key in <prefix>.<secret> form | yes |
source_id | Sources → your source, a 19-digit id | recommended |
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"},
)| option | type | notes |
|---|---|---|
user_id | str | your identifier for a person |
account_id | str | your identifier for a company |
properties | dict | event properties |
user_attributes | dict | traits to set on the person |
account_attributes | dict | traits to set on the account |
timestamp | datetime | int | epoch 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() # boolThe 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 shutdownThe 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
| response | behaviour |
|---|---|
| 413, batch > 1 | halve the batch size, retry |
| 413, batch = 1 | drop the event, log it, return the width to full |
| 429 | honour Retry-After, else exponential backoff |
| 5xx, 408, timeout | exponential backoff, floored at 100ms, capped at 10 minutes |
| other 4xx | drop the batch, log the status and body |
| 5 consecutive failures | stop 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 type | sent as |
|---|---|
str, int, float, bool | as-is |
datetime | epoch milliseconds |
set | list |
None | dropped, never reaches the wire |
dict, list | as-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 timestamp | what happens |
|---|---|
| before 2010-01-01 | request rejected, error names the threshold |
| 2010 to 2040 | stored as given |
| after 2040-01-18 | silently 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.
| option | type | default | what it does |
|---|---|---|---|
org | str | — | your organization name, from the console URL |
project | str | — | your project name, from the console URL |
api_key | str | — | a public key, <prefix>.<secret> |
source_id | str | None | the source events are attributed to. Keep it a string |
host | str | api.intempt.com | accepts host or host:port |
scheme | str | https | http is accepted for local testing only |
path | str | "" | prefix for every request path, for a reverse proxy |
timeout | float | 10.0 | seconds, applied to connect and to read |
keep_alive | bool | True | reuse one connection. Fixed at construction |
debug | bool | False | log the method and path of every request. Never the body |
max_request_events | int | 50 | hard ceiling on events per request, independent of batch.size |
max_concurrent_requests | int | 1 | in-flight requests. 1 keeps events in order |
batch | BatchOptions | None | off by default; see Delivery |
logger | logger | logging.getLogger("intempt") | anything with debug/info/warning/error |
BatchOptions:
| option | type | default | what it does |
|---|---|---|---|
size | int | 50 | events per request, capped by max_request_events |
flush_ms | int | 5000 | milliseconds before an incomplete batch is sent |
max_queue | int | 10000 | events held in memory. Beyond this, new events are dropped and logged |
flush_on_exit | bool | True | drain 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)| exception | when |
|---|---|
IntemptConfigError | bad configuration or arguments — never retried |
IntemptApiError | the API answered, or the transport failed |
IntemptError | base 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
- Send one
trackcall with auser_idyou can recognise. - Open Sources → your source → Live events in the console.
- The event appears with the name you sent.
| symptom | cause |
|---|---|
| every call raises with 401 | wrong api_key, or the key belongs to another project |
| 404 on every call | wrong org or project — check the console URL |
| events land in the wrong source | source_id lost precision; keep it a string |
| commerce accepted but no product shows | ingestion returns 201 for unknown ids |
| nothing arrives and nothing errors | you 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.
