SalesOS

Python SDK

salesos — typed resources, retries, idempotency, pagination, OAuth helpers and webhook verification for Python 3.9+, sync and asyncio.

salesos is the official Python client. It mirrors the TypeScript SDK: models are generated from the OpenAPI document and the event catalog, and it implements the contracts in these guides for you — retries and rate limits, idempotency keys, pagination, errors, OAuth and webhook signatures.

It needs Python 3.9 or newer and has one runtime dependency, httpx. It ships type hints (py.typed) and works with mypy and pyright.

The package is not on PyPI yet. Until it is, install it from the SalesOS repository: pip install ./packages/sdk-python.

Quick start

import os
from salesos import SalesOS

client = SalesOS(api_key=os.environ["SALESOS_API_KEY"])

lead = client.leads.create({"firstName": "Ada", "lastName": "Lovelace", "company": "Analytical"})
client.leads.update(lead["id"], {"status": "CONTACTED"})

for deal in client.opportunities.iterate(owner="me", open=True):
    print(deal["name"], deal.get("amount"))

The async client has the same resources; every call is awaited and iterate() returns an async iterator:

from salesos import AsyncSalesOS

async with AsyncSalesOS(api_key=key) as client:
    async for lead in client.leads.iterate(status="NEW"):
        ...
OptionDefault
api_keySent as X-API-Key
access_tokenBearer token: a string, or a function (sync or async) returning the current one
authAny AuthProvider: ClientCredentialsAuth, OAuthTokenAuth, SessionAuth or your own
base_urlhttps://www.salesos.org/v1 is added to paths that don't carry it
organization_idSent as X-Organization-ID (session tokens with several memberships)
timeout30Seconds, per attempt
max_retries2Extra attempts for retryable failures
max_retry_delay30A longer Retry-After is not waited for; the error is raised
auto_idempotencyTrueRandom Idempotency-Key on every POST/PATCH
on_retryCalled with RetryInfo(attempt, delay, error) before each retry
http_client / transportBring your own httpx.Client / transport (proxies, tests)

Resources

AttributeMethods
leads, contacts, accounts, opportunities, tasks, quotes, orders, caseslist, iterate, pages, get, create, update, delete
activitiesthe same without update (activities are immutable)
custom_objectsdefinitions, plus .records(object_id) with list / iterate / get / create / update / delete
webhooksCRUD, activate, deactivate, rotate_secret, test, deliveries, iterate_deliveries, delivery, replay
eventscatalog(), get(type) (no credentials)
bulkcreate_job (upload or inline), list_jobs, get_job, cancel_job, results, failures, download_results, download_failures, wait_for_job
custom_actionsCRUD, rotate_secret, test, run, invocations
appsruntime_installation() (your app's OAuth token); publishing and installs (session)
metadatatypes, retrieve, deploy, run_tests, list_deploys, get_deploy, check_again, cancel, wait_for_deploy, get_test_policy, set_test_policy
functionslist, get, create, update, delete, create_version, deploy, rollback, set_secret, delete_secret, test, invoke, invocations, invocation
api_usagesummary(days=…, principal_type=…, principal_id=…)
event_streamtrigger(type) (streaming itself is in the CLI and the TypeScript SDK)
marketplacelistings, iterate_listings, categories, listing, reviews (no credentials); directory, save_review (session); .publisher (submissions, domains, replies)

Anything else: client.request("GET", "/pipelines", query={...}), or by operation id:

from salesos.operations import call_operation

stages = call_operation(client, "pipelines_findAll")

Every method takes options={"timeout": 5, "idempotency_key": "...", "headers": {...}, "max_retries": 0}.

Retries and idempotency

  • GET, HEAD and OPTIONS are retried on network errors, timeouts, 408 and 5xx with jittered backoff.
  • POST and PATCH carry an automatic Idempotency-Key, the same on every attempt, so they are retried safely; a write without a key (auto_idempotency=False or options={"idempotency_key": False}) is never retried. Multipart uploads are never retried.
  • 429 is retried for any method when Retry-After says when; 503 honours Retry-After too.
  • 409 IDEMPOTENCY_REQUEST_IN_PROGRESS (a duplicate still running) is waited out.
  • The OAuth token endpoint is never retried: codes are single-use and a reused refresh token revokes its whole family.

Errors

Every failure raises SalesOSError: status (0 when no response arrived), code, message, request_id, retry_after (seconds), headers and the parsed body.

from salesos import SalesOSError

try:
    client.opportunities.update(deal_id, {"stage": "CLOSED_WON"})
except SalesOSError as e:
    if e.code == "STAGE_REQUIREMENTS_NOT_MET":
        print(e.body["missingFields"])
    elif e.is_rate_limited:
        ...
    else:
        raise

OAuth

Client credentials (your app acting as its integration user) — tokens are fetched on first use and refreshed shortly before they expire:

from salesos import ClientCredentialsAuth, OAuthClient, SalesOS

oauth = OAuthClient(os.environ["SALESOS_CLIENT_ID"], os.environ["SALESOS_CLIENT_SECRET"])
client = SalesOS(auth=ClientCredentialsAuth(oauth, scope=["READ_LEADS", "WRITE_LEADS"]))

Authorization code with PKCE (works for public clients without a secret):

oauth = OAuthClient("sos_ci_...")
req = oauth.create_authorization_request(
    redirect_uri="https://app.example.com/oauth/callback",
    scope=["READ_LEADS", "offline_access"],
)
# Store req.state and req.code_verifier, then redirect the user to req.url.

code = oauth.parse_callback(request_url, stored_state)   # checks state, error and iss (RFC 9207)
tokens = oauth.exchange_code(code=code, redirect_uri=req.redirect_uri, code_verifier=stored_verifier)

Then let the SDK keep the token fresh. Refresh tokens rotate; persist each new set in on_tokens:

from salesos import OAuthTokenAuth, StoredTokens

auth = OAuthTokenAuth(
    oauth,
    StoredTokens(tokens["access_token"], tokens.get("refresh_token"), tokens["expires_at"]),
    on_tokens=lambda t, raw: save(t.access_token, t.refresh_token, t.expires_at),
)
client = SalesOS(auth=auth)

OAuthClient also has refresh, client_credentials, revoke, introspect and userinfo, each with an a-prefixed async twin.

Bulk jobs

job = client.bulk.create_job("lead", "upsert", file="leads.csv", external_id_field="email")
job = client.bulk.wait_for_job(job["id"], on_progress=lambda j: print(j["processedRows"], "rows"))
if job["failedRows"]:
    client.bulk.download_failures(job["id"], "failures.csv")

file is a path, bytes or a binary file object; pass inline CSV / JSON-lines as data= or rows as records=. Exports: create_job("contact", "export", fields=["email", "firstName"]).

Webhooks

Verify deliveries against the raw body, before parsing JSON:

from salesos import SalesOSError, construct_event

# Flask
@app.post("/salesos/webhooks")
def salesos_webhook():
    try:
        event = construct_event(request.get_data(), request.headers, os.environ["SALESOS_WEBHOOK_SECRET"])
    except SalesOSError:
        return "", 400
    if already_processed(event["id"]):   # ids are stable across retries and replays
        return "", 200
    handle(event["type"], event["data"])
    return "", 200

verify_webhook_signature(raw_body, headers, secret) returns a bool instead. Both check X-SalesOS-Signature (t=<unix seconds>,v1=<hex HMAC-SHA256(secret, "<t>.<raw body>")>), reject timestamps more than 300 seconds away (tolerance=), accept any matching v1 (secret rotation) and compare in constant time. The SDK's tests run the server's own signer and check both directions.

App backends

from salesos import installation_secret, verify_context_token

# Each org's components are signed with a secret derived from your app secret and the org id.
secret = installation_secret(os.environ["SALESOS_APP_SECRET"], event["organizationId"])

# The context token an extension frame forwards to your backend.
claims = verify_context_token(token, secret, app_slug="acme-enrich")
print(claims["sub"], claims["org"], claims.get("rec"))

Models

salesos.models holds a TypedDict for every schema in the public OpenAPI document (Lead, CreateLeadDto, Opportunity, …) and salesos.events the webhook event types and their data shapes. They are generated; refresh them after a public API change:

cd packages/sdk-python
python scripts/generate.py --fetch    # refresh spec/ from production and regenerate
python scripts/generate.py --check    # CI: exit 1 when the generated files are stale
python scripts/make_async.py --check  # the async resources are derived from the sync ones

Metadata, functions and usage

pkg = client.metadata.retrieve()
deploy = client.metadata.deploy(pkg["files"], check_only=True)
result = client.metadata.wait_for_deploy(deploy["id"])
run = client.metadata.run_tests(pkg["files"], run_tests="ALL")   # check-only, runs the tests
tested = client.metadata.wait_for_deploy(run["id"])              # tested["testResults"]["results"]
client.metadata.get_test_policy()                               # {"requireTestsFor": [...]}
client.functions.invoke("score_lead", {"leadId": lead_id})
usage = client.api_usage.summary(days=30)
client.event_stream.trigger("lead.created")   # to `salesos listen` sessions

deploy(..., run_tests="NONE" | "SPECIFIED" | "ALL") picks which tests run first; salesos.METADATA_TYPES lists every component type. AsyncSalesOS has the same methods. Streaming live events is in the CLI (salesos listen) and the TypeScript SDK.

On this page