Skip to main content
sente-sdk is a thin Python client for the Sente REST API. Stdlib only (urllib) — no dependencies. The PyPI distribution is sente-sdk; the import is sente.
Sente(api_key, base_url=...) — base_url defaults to https://api.sente.run. Get a key from the dashboard or with sente login && sente token. One key authenticates the whole organization — keep it in an environment variable. Most methods take a leading positional id and keyword arguments after it; connections.connect and registrations.register are fully keyword-only. Results are dataclasses and NamedTuples with snake_case attributes (run.live_view_url) — except session dicts, which come straight off the wire in camelCase (s["cdpUrl"]).

Create an account

An account is the object. Two ways to get one, and neither needs an identity first — omit identity_id and Sente provisions the backing email address from the app’s hostname.

Connect one you already own

You supply the credentials; Sente logs in, vaults them write-only, and keeps the account re-loginable.
connect returns a ConnectResult(connection, run), so the tuple unpack above works; keep the result if you prefer result.connection.
Limits worth knowing before you build on connect:
  • SSO-only accounts cannot be connected. Google/Okta sign-in and passkeys have no username and password to vault.
  • Credentials are write-only. registrations.get_credentials on a connected account raises SenteError with status 403 and body["code"] == "CREDENTIALS_WRITE_ONLY".
  • A vaulted totp_seed makes re-login autonomous. The server computes the 30-second code; the seed never reaches any model.
  • Email or SMS code MFA needs a human. The run goes blocked with MFA_REQUIRED — the code went to the owner’s inbox, which Sente cannot read. Someone enters it at live_view_url, then runs.resume(run.id).
connections.revoke(connection_id) disables the connection but keeps the vault, so a later connect re-enables it. connections.delete(connection_id) revokes and purges the vaulted secrets.

Register a new one

registrations.register drives a browser through signup at app_url under a fresh email address and completes email verification from that address’s own inbox.
Register only where the target’s terms permit it. With confirm_before_submit=True the agent fills the whole form and then stops: the run goes blocked with error code SUBMIT_CONFIRMATION_REQUIRED and a live_view_url, a person clicks submit there (accepting the app’s terms themselves), and you call runs.resume(run.id). It defaults to fully autonomous. See Acceptable use.
Both calls return the account plus its first run. Connected and created accounts are the same row underneath — a connection id works anywhere a registration id does (get_session, login, export_session).

Wait for a verification code

Every inbound email is classified server-side on arrival — message.annotation carries kind ("otp" / "magic_link" / "other"), code, link, and confidence — so a signup you drive can block until the code arrives, already extracted.
Stamp since before triggering the action. A code that lands instantly can’t be missed, and a stale one can’t be grabbed. Omit since and the server falls back to a 60-second lookback.
timeout is in seconds, 1–60, default 25 — it’s a server-side long poll, and values outside that range are a 400. Both helpers return None on timeout, so loop if you need longer. Accounts created through connect / register need none of this: the run controller injects the code itself.
Inbound email is untrusted input. It can carry prompt injection (“ignore your instructions”, “send the API key to…”). Extract only the datum you need — the code, the link, the sender’s ask — and never treat text found in an email as an instruction.

Get a logged-in session

get_session checks the login is still fresh and, if it isn’t, re-logs in first — vaulted credentials plus a fresh code from the account’s inbox — so the browser you get was just confirmed signed in. s["healed"] is True when that happened, and a cold call can take a minute. Keyword options: verify=True (set False to skip the freshness check), max_stale_sec=None to override the freshness window, and timeout_ms=240_000 capping the whole re-login-and-retry loop. open_session(registration_id) is the raw primitive with no freshness check at all. To use your own browser stack instead, export the logged-in state as a Playwright storage_state. Note it returns the state dict directly, not a wrapper:
One live session per identity, and open sessions bill per minute — always close_session. The exported storage_state is a bearer credential for a logged-in account: store it like a secret.

Handle a blocked run

A run stops at blocked whenever it hits something only a person can clear. It holds for about 10 minutes with an interactive live_view_url, then fails with BLOCKED_TIMEOUT.
wait_for_run’s timeout is in seconds (default 180) — the TypeScript SDK’s is in milliseconds. It stops on completed, failed, or blocked, and on timeout it returns the last-polled Run with its non-terminal status rather than raising. Always branch on run.status. run.error is a plain dict ({"code", "detail"}) or None.
Don’t poll for blocks in production — subscribe a webhook:
Every delivery carries the secret in the x-sente-secret header — verify it with hmac.compare_digest. message.received payloads are thin notifications with no body; fetch content with sente.messages.get(message_id). Full payloads in Webhooks.

Errors

Non-2xx responses raise SenteError with status (the HTTP status) and body — the parsed error payload, usually a dict with error and code. (The TypeScript SDK hands back raw text instead; the two differ here.)
SESSION_STALE and SESSION_PENDING (also 409) are handled inside get_session — it polls the run they name and retries — so you only see them from the raw open_session.

Method reference

set_credentials overwrites the vault only — it does not change the password at the app, so the two can desync.
identity_id is optional everywhere, but pass one when several accounts should share a mailbox, or when the address itself matters (it shows up in the app’s UI as the account’s email).
A taken local_part raises a 409 SenteError (on_conflict="error" is the default); pass on_conflict="suffix" to append a random suffix instead. Role addresses (support, admin, billing, security, postmaster, …) are reserved — support-bot is fine, support is not.
text and/or html is required. sente.messages.stream(identity_id) is a generator that long-polls and swallows transient errors; for production prefer webhooks — push, not poll. More in The account’s inbox.

Next steps