Python SDK
The official Python library: one typed method per operation, with retries, idempotency, pagination and dry runs built in. Not yet published to PyPI.
| Package | Python | Status |
|---|---|---|
mb-suite | 3.9+ | Preview, on the 2026-11-01 contract. Not yet published to PyPI. |
Installation
pip install mb-suite- Python 3.9 or later. No dependencies: it uses the standard library (
urllib), or the HTTP client you plug in (see “Transports”). - Server-side only: an API key is a secret. Never put it in a browser or a mobile app.
- An API key (
mbs_live_…) or an OAuth token (mbo_…): see Authentication.
Your first request
from mb_suite import MBSuite, response_meta
mb = MBSuite() # without api_key, it reads the MB_API_KEY environment variable
me = mb.platform.me() # GET /v1/me
print(me["actor"]["uid"], me["org"]["id"])
print(response_meta(me).request_id) # req_…: include it if you write to usEvery operation has a stable id and the method follows it as is: crm.deals.create is mb.crm.deals.create() and crm.custom_fields.list is mb.crm.custom_fields.list(). A segment that is a Python keyword gets a trailing underscore: contacts.contacts.bulk.import is mb.contacts.contacts.bulk.import_(). The Reference shows each one's id.
- 1First, the path parameters, as strings and in the order of the path:
mb.crm.deals.get("deal_123"). - 2Then, the parameters: one
dictin snake_case, just like the API (the query of aGET, the body of the rest). - 3Last, the request options as keyword arguments:
organization,idempotency_key,confirm_token,timeout,max_retriesandextra_headers. An option that does not exist is aTypeError.
List, create and edit
page = mb.crm.deals.list({"status": "open", "limit": 50})
created = mb.crm.deals.create({
"title": "Plan anual — Acme",
"value": 4800,
"currency": "USD",
"pipeline_id": "PIPELINE_ID",
"stage_id": "STAGE_ID",
})
deal_id = created["deal_id"]
mb.crm.deals.move(deal_id, {"stage_id": "NEXT_STAGE_ID"})
mb.crm.deals.update(deal_id, {"updates": {"value": 5200}})
deal = mb.crm.deals.get(deal_id)Results are plain dicts, the JSON of the API. Parameters and results are typed from the contract with TypedDict (in mb_suite.types) and every method carries its operation's description: your editor shows it, and mypy or Pyright check them.
Pagination
Every paginated list has a …_all() twin that walks every page and yields the items one by one, fetching each page as you go. A page can come back empty with has_more set to True: the walk keeps going. Break out of the loop when you have what you need. See Pagination.
# Follows next_cursor until has_more is False; limit is the size of each page
for deal in mb.crm.deals.list_all({"status": "open", "limit": 200}):
print(deal["id"], deal["title"])Dry run and confirmation
The operations that need confirmation —bulk, irreversible, the ones that send or change something outside MB Suite or have a separate cost, and the ones that always ask— have .dry_run(): it writes nothing and returns the preview and confirm(), which runs the same operation with the same parameters and the preview's confirm_token (valid for 10 minutes). Pass your own idempotency_key to .dry_run() or to confirm(): it goes with the confirmation, never with the dry run, which writes nothing. See Dry run and confirm_token.
run = mb.crm.deals.bulk.delete.dry_run({"deal_ids": ["DEAL_A", "DEAL_B"]})
print(run.preview["summary"], run.preview["counts"]) # nothing was deleted yet
result = run.confirm() # within 10 minutes
print(result["deleted"])- Calling it without a token raises
ConfirmationRequiredError. - If the
confirm_tokenarrives some other way (an approval, an agent), pass it as an option:mb.crm.deals.bulk.delete(params, confirm_token=token).
Retries and idempotency
- Every mutation goes out with an
Idempotency-Key(a UUID the SDK generates, or yours) and keeps it across retries: if a response is lost, the retry returns the stored one instead of running twice. - Retried on their own, up to 3 times with exponential backoff (0.5 s, 1 s, 2 s): network errors, timeouts, a 429 (
rate_limited,external_rate_limited, a shortcooldown_active), a 409idempotency_in_progressand 5xx (except the 502ai_generation_failed). When the response carriesRetry-After, it waits exactly that. - Not retried: the rest of the 4xx, a 429
quota_exceeded(the daily quota resets at 00:00 UTC), a 502ai_generation_failed(retrying calls the AI again and spends credits if it answers: your call) or aRetry-Afterlonger than 60 seconds. The error comes back withretry_afterso you can reschedule.
To make a logical operation safe across your own retries, give it your own key (valid for 24 hours). The same key with a different body raises IdempotencyError. See Idempotency.
# The same key on a retry of yours (another process, another deploy) returns the stored response
mb.crm.deals.create(new_deal, idempotency_key=f"import-{row_id}")Errors
Every error extends MBSuiteError and carries the API's single error format: type, code (stable: branch on it), message, status, param, request_id, doc_url, details and retry_after. The class depends on the type; the names do not shadow Python's built-ins (PermissionDeniedError, APIConnectionError, APITimeoutError):
| Class | type | HTTP |
|---|---|---|
InvalidRequestError | invalid_request | 400 |
AuthenticationError | authentication | 401 |
PaymentRequiredError | payment_required | 402 |
PermissionDeniedError | permission | 403 |
NotFoundError | not_found | 404 |
ConflictError | conflict | 409 |
ConfirmationRequiredError | confirmation_required | 409 |
IdempotencyError | idempotency | 422 |
RateLimitError | rate_limit | 429 |
QuotaExceededError | quota | 429 |
UpstreamError | upstream | 502 |
ServiceUnavailableError | unavailable | 503 |
MaintenanceError | maintenance | 503 |
InternalServerError | internal | 5xx |
APIConnectionError, APITimeoutError | connection | — |
from mb_suite import MBSuiteError, NotFoundError, RateLimitError
try:
deal = mb.crm.deals.get(deal_id)
except NotFoundError:
deal = None
except RateLimitError as err:
print(f"Retry in {err.retry_after} s")
except MBSuiteError as err:
print(err.code, err.request_id, err.doc_url)
raiseInvalidRequestError adds issues (which field and why), ConfirmationRequiredError adds reason (expired, mismatch), QuotaExceededError is a RateLimitError with quota and MaintenanceError is a ServiceUnavailableError. Every code is in Errors.
Accounts
A workspace API key works on the Accounts in its scope with MB-Organization. Choose the Account for the whole client, for one request, or with a client bound to it. mb.platform.orgs.list() returns the ones the API key reaches. See Workspaces and Accounts.
# For every request of the client
mb = MBSuite(organization="ACCOUNT_ID")
# For one request (None = the key's own organization)
mb.crm.deals.list({}, organization="OTHER_ACCOUNT_ID")
# A client for that Account; the original one does not change
account = mb.with_options(organization="ACCOUNT_ID")Response metadata
response_meta(result) returns the request_id, the version, the burst window and today's quota (to slow down before a 429, see Limits) and whether it was the stored response of an earlier request. It is also the last_response attribute of the result, which is not a key: it does not show up in json.dumps or dict(result).
meta = response_meta(page)
meta.request_id # "req_…"
meta.rate_limit # LimitState(limit, remaining, reset): the key's burst window (10 s)
meta.quota # LimitState(limit, remaining, reset): today's calls
meta.idempotent_replayed # True when it is the stored response of an earlier requestThe SDK sends MB-Version: 2026-11-01 on every request: the version its types describe. If a response comes from a version being retired, it warns once with an APIVersionDeprecationWarning and its Sunset date. See Versions.
Types and raw requests
from mb_suite.types import CrmDealsCreateParams, CrmDealsListItem
new_deal: CrmDealsCreateParams = {"title": "Acme", "value": 4800, "currency": "USD", "pipeline_id": "P", "stage_id": "S"}
# Any endpoint by method and path, with the same retries and errors
mb.request("POST", "/crm/deals/DEAL_ID/archive", body={"archived": True})For each operation (crm.deals.list → CrmDealsList): …Params, …Result and, for paginated lists, …Item. By default the client talks to https://api.mb-suite.com/v1; base_url points it somewhere else (the same API answers at https://mb-suite.com/api/platform/v1).
Transports
By default the SDK uses the standard library (system certificates, the HTTP(S)_PROXY variables, no redirects followed). To use another HTTP client, or to test without a network, pass any object with a send(request) -> HTTPResponse method: it sends one request; retries, keys and errors stay with the client.
import requests
from mb_suite import HTTPRequest, HTTPResponse, MBSuite
class RequestsTransport:
def __init__(self) -> None:
self.session = requests.Session()
def send(self, request: HTTPRequest) -> HTTPResponse:
r = self.session.request(request.method, request.url, headers=request.headers, data=request.body,
timeout=request.timeout, allow_redirects=False)
return HTTPResponse(status=r.status_code, headers={k.lower(): v for k, v in r.headers.items()}, body=r.content)
mb = MBSuite(transport=RequestsTransport())To receive events, verify the MB-Signature header of each delivery as shown in the Webhooks guide (it has the Python snippet).