Skip to main content

Tyxter Python SDK

Typed, synchronous Python client for the Tyxter Messaging API. The package uses httpx, supports Python 3.10–3.13, and tracks the canonical public launch manifest.

The SDK is alpha software. Additive response fields are compatible and are tolerated at runtime. Removing or renaming a public method, field, or stable error.code requires a deprecation cycle.

The 0.8.0 source line is a non-publishing source candidate. Editing the version or these docs does not publish a package, create a tag, or change PyPI state.

Install

pip install tyxter currently installs the published 0.4.0 artifact, pending a separate release decision. It does not include the 0.8 candidate documented in this checkout. A source checkout/install evaluates that candidate only; it does not publish it:

git clone https://github.com/tyxter-dev/tyxter-python.git
cd tyxter-python
git checkout CANDIDATE_COMMIT_OR_BRANCH
uv sync --locked --extra dev
uv run python -c "import tyxter; print(tyxter.__version__)"

For routine development checks in that environment:

uv run ruff format --check .
uv run ruff check .
uv run mypy
uv run pytest

First message

import os

from tyxter import Tyxter

with Tyxter(api_key=os.environ["TYXTER_API_KEY"]) as client:
    account = client.account.retrieve()
    sender_id = client.sandbox.quickstart()["sender"]["default_sender_id"]
    if sender_id is None:
        raise RuntimeError("sandbox sender is not configured")

    # A customer inbound opens WhatsApp's 24-hour free-form service window.
    client.sandbox.inbound_messages.create(
        {
            "from": "+15555550100",
            "to": sender_id,
            "type": "text",
            "text": {"body": "ping"},
        },
        idempotency_key="idem_python_open_window_001",
    )
    message = client.whatsapp.send_text(
        {
            "from": sender_id,
            "to": "+15555550100",
            "body": "Hello from Tyxter Python.",
        },
        idempotency_key="idem_python_first_message_001",
    )
    detail = client.messages.retrieve(message["id"])

print(account["environment"]["kind"], detail["status"], message["trace_id"])

Set base_url="http://localhost:3001" when using the local stack.

Native Pix order details

WhatsApp interactive sends accept the existing button/list payloads and the typed native order_details Pix variant. A recipient can remain an E.164 string or use the structured calling-code form; the SDK sends the latter unchanged as the public API's country_calling_code and national_number fields.

client.whatsapp.send_interactive(
    {
        "from": sender_id,
        "to": {"country_calling_code": "55", "national_number": "11999999999"},
        "interactive": {
            "type": "order_details",
            "header": {"type": "image", "link": "https://cdn.example.test/order.png"},
            "body": {"text": "Review and pay"},
            "footer": {"text": "Tyxter Store"},
            "action": {
                "name": "review_and_pay",
                "parameters": {
                    "reference_id": "order_123",
                    "type": "physical-goods",
                    "payment_type": "br",
                    "payment_settings": (
                        {
                            "type": "pix_dynamic_code",
                            "pix_dynamic_code": {
                                "code": "000201010212",
                                "merchant_name": "Tyxter Store",
                                "key": "merchant@example.com",
                                "key_type": "EMAIL",
                            },
                        },
                    ),
                    "currency": "BRL",
                    "total_amount": {"value": 12990, "offset": 100},
                },
            },
        },
    }
)

Native Pix order details are for a Brazil WABA eligible for Meta's Payments API. The API accepts the request after its own checks, but Meta evaluates WABA and message eligibility at provider-send time, so acceptance does not promise delivery. This interactive message has no Tyxter payment_id resolution or order-status update, and Meta does not reconcile settlement; confirm settlement with the merchant or PSP.

Inbound audio transcription and retries

Use messages.request_transcription(message_id, {"language": "pt"}) to opt an inbound WhatsApp-audio message into asynchronous transcription, then poll the same receipt with messages.retrieve_transcription(message_id). The typed receipt exposes pending, succeeded, or failed, its text/error fields, and its trace_id. A pending or succeeded request replays its receipt only when the language is omitted or matches; a failed receipt returns transcription_retry_required.

Requesting transcription never buys a second attempt. Start one bounded manual retry only with messages.retry_transcription(). Its caller-supplied, nonblank idempotency key is trimmed before sending; reuse that same key to replay the accepted retry without opening another generation. A retry always reuses the original source; it cannot replace media that has expired or is structurally unavailable. If the API returns transcription_retry_rate_limited, wait its retry_after_ms value and replay the same logical retry command with the same key. Use a fresh key only for a distinct retry command.

Subscribe to both terminal events: message.media_transcribed and message.media_transcription_failed. The success event carries the transcript's speech, provider, model, and duration; the failure event is failure-safe and carries its stable error_code without those success-only fields.

client.messages.retry_transcription(
    "msg_123",
    {"language": "pt"},
    idempotency_key="transcription-retry-msg_123-1",
    trace_id="trc_transcription_retry",
)

Inbound media, typing, and message observability

Inbound message reads and lists expose a typed media descriptor with the Tyxter mda_* asset ID. Use client.media.list(source="inbound_provider") to find imported provider media and client.media.create_download_url(asset_id) to mint a fresh, short-lived download_url; do not assume a previous capability URL remains valid. A consumed inbound descriptor also has a download hint ({"method": "GET", "path": ...}); treat it as the API's current download affordance rather than a durable provider URL. Failed media carries failure, while expired and deleted media carry neither a download nor a failure.

For WhatsApp OGG/Opus mono audio, client.whatsapp.send_media(...) accepts "voice": True to request a native voice note; omit it or pass False for ordinary audio. Instagram media has a separate input shape: ordinary image, document, audio, and video sends remain valid, but it deliberately has no voice field. An inbound WhatsApp sender can be phone-less, represented on reads only as {"type": "phone_e164", "id": ""}; do not reuse it as an outbound recipient. type == "unsupported" means the provider withheld the content and supplies an unsupported descriptor when available, whereas type == "unknown" means content arrived but has no typed projection yet—read its raw payload through retrieve() or list(include="payload") instead of treating it as a refusal. A default list row may have payload set to None.

For a WhatsApp typing indicator, pass the Tyxter message ID from message.received.data.message_id. The webhook envelope's top-level id is the event ID and data.provider_message_id is Meta's reference, so neither targets client.messages.typing(). The indicator response is an accepted receipt.

Message accepts and reads expose trace_id for correlation. retrieve() also returns the message event timeline, while message rows expose status_reason, terminal error details, provider error information, and delivery_unconfirmed_at when provider acceptance has not yet been followed by a delivery status. An accepted send is not a delivery guarantee.

The complete deterministic example at examples/sandbox_send_and_verify.py sends a sandbox message, retrieves it, polls the public webhook-listen API, and verifies the returned raw-body signature preview without using dashboard or internal routes.

TYXTER_API_KEY=tx_sandbox_... \
TYXTER_WEBHOOK_SECRET=... \
python examples/sandbox_send_and_verify.py

Resources

The client exposes snake-case resource namespaces:

  • account, api_keys, ai_agents, agentic_payments, audiences, and projects
  • automations, automation_runs, and automation_webhooks
  • batches, billing, contacts, data_retention, feedback, fiscal, and flows
  • llm, llm_routes, media, messages, meta_signup_sessions, and payments
  • phone_numbers, provider_connections, and provider_credential_setup_sessions
  • rate_cards, sandbox, templates, usage, webhook_endpoints, and webhook_events
  • channel-native conveniences: whatsapp, instagram, and whatsapp_channels

Request and response dictionaries are TypedDict contracts exported from tyxter.types. Write methods expose idempotency_key and trace_id only where the canonical endpoint manifest supports those headers.

Provider credential setup keeps create() and retrieve() source-compatible with ProviderCredentialSetupSessionResponse, including mutable status and completion fields. Use create_result() or retrieve_result() when strict type narrowing must distinguish provider-connection, TTS, and STT completion axes.

Template authoring

Omit parameter_format on create or generation to select the compatible "POSITIONAL" default. On update, omission retains the draft or rejected template's current format; on duplicate, it inherits the source template's format. Set "NAMED" when BODY values are bound by their parameter names. The approved template version, not a send request, determines how its variables are resolved.

from time import sleep

named_template = client.templates.create(
    {
        "name": "order_tracking",
        "language": "en_US",
        "category": "utility",
        "parameter_format": "NAMED",
        "components": [
            {
                "type": "BODY",
                "text": "Hi {{customer_name}}, order {{order_id}} is ready.",
                "example": {
                    "body_text_named_params": [
                        {"param_name": "customer_name", "example": "Ana"},
                        {"param_name": "order_id", "example": "ORD-123"},
                    ]
                },
            }
        ],
    }
)

client.templates.submit(named_template["id"])
approved_template = client.templates.retrieve(named_template["id"])
while approved_template["status"] == "submitted":
    sleep(5)  # Use application-appropriate polling/backoff in production.
    approved_template = client.templates.retrieve(named_template["id"])
if approved_template["status"] != "approved":
    raise RuntimeError(f"Template was not approved: {approved_template['status']}")

client.whatsapp.send_template(
    {
        "from": "pn_123",
        "to": "+5511999999999",
        "name": named_template["name"],
        "language": named_template["language"],
        # No send-time parameter_format: the approved version selects NAMED.
        "variables": {"customer_name": "Ana", "order_id": "ORD-123"},
    }
)

Standalone COPY_CODE is a marketing coupon button, not an authentication OTP button with {"type": "OTP", "otp_type": "COPY_CODE"}. Author it without a text field; the service accepts one nonempty coupon example of at most 20 characters on a marketing template.

from time import sleep

coupon_template = client.templates.create(
    {
        "name": "winter_coupon",
        "language": "en_US",
        "category": "marketing",
        "components": [
            {"type": "BODY", "text": "Use this coupon at checkout."},
            {"type": "BUTTONS", "buttons": [{"type": "COPY_CODE", "example": "WINTER25"}]},
        ],
    }
)

client.templates.submit(coupon_template["id"])
approved_coupon = client.templates.retrieve(coupon_template["id"])
while approved_coupon["status"] == "submitted":
    sleep(5)  # Use application-appropriate polling/backoff in production.
    approved_coupon = client.templates.retrieve(coupon_template["id"])
if approved_coupon["status"] != "approved":
    raise RuntimeError(f"Template was not approved: {approved_coupon['status']}")

client.whatsapp.send_template(
    {
        "from": "pn_123",
        "to": "+5511999999999",
        "name": coupon_template["name"],
        "language": coupon_template["language"],
        "components": [
            {
                "type": "button",
                "sub_type": "copy_code",
                "index": 0,
                "parameters": [{"type": "coupon_code", "coupon_code": "WINTER25"}],
            }
        ],
    }
)

client.batches.create intentionally does not support this COPY_CODE send shape: its batch contract has no per-recipient button-parameter source.

Phone name-review reads

Phone reads expose three distinct name facts. display_name remains the customer-entered value; verified_name is nullable Meta-verified display-name evidence; and name_review is nullable or the latest durable review {requested_name, decision, reason, reviewed_at}. Its decision is an open Meta string. pending_name_review is separately nullable or {requested_name, status, observed_at}: status is nullable and, when present, an open Meta string; observed_at shares the freshness fact in meta_health_synced_at. Neither a null status nor a null pending or durable review block implies approval. SDK list and retrieve reads return these API values; they do not trigger a live Meta fetch.

Provider availability observations

Provider connection availability fields are advisory evidence, not SDK send enforcement. An absent or null send_capability means there is no trusted observation: it is unknown and establishes neither availability nor unavailability. A blocked value does not change the connection status or block a client send. send_block_codes, waba_send_capabilities, and waba_ban_date provide diagnostic Meta observations, including WABA-specific evidence and its observed timestamp. Policy-warning strings remain open provider values. A flow's nullable provider_missing_since is an observation: null means the provider resolves or has never been checked, while a timestamp is the first reconciliation failure. Verify provider warning and scheduled-disable webhook envelopes with the existing raw-body signature verifier.

Promotional credit reads and events

Top-up responses and credit.topped_up webhook events can report both payment_method and provider as "promotion" for a separately granted campaign bonus. Historical webhook events may omit provider, which the SDK never infers. There is no campaign, redemption, or promotion-creation operation in this SDK. A response kind of "cash" can coexist with the promotional discriminants; it does not establish cash settlement or a provider charge.

Pagination

List methods return cursor pages. Continue with next_cursor only when has_more is true:

cursor = None
while True:
    page = client.messages.list(limit=100, starting_after=cursor)
    for message in page["data"]:
        print(message["id"], message["status"])
    if not page["has_more"]:
        break
    cursor = page["next_cursor"]

Idempotency and errors

Tyxter does not implicitly retry writes. Reuse one idempotency key for retries of the same logical operation and choose your retry policy from the stable error fields:

  • Unsupported endpoints do not expose an idempotency-key argument because the manifest disallows that header.
  • Supported endpoints accept an optional caller key and forward it when one is supplied.
  • Required endpoints always send a key. feedback.create() generates one when omitted (and rejects an explicit blank key); transcription retry requires the caller to supply a nonblank key.

Flow creation, LLM route upsert/delete, AI Agent completion, and automation webhook-secret rotation all accept the idempotency_key keyword argument.

from tyxter import TyxterAPIError, TyxterConnectionError

try:
    message = client.messages.create(payload, idempotency_key="order_123_send_1")
except TyxterAPIError as error:
    print(error.status_code, error.code, error.request_id, error.trace_id)
    if error.retry_after_ms is not None:
        print("retry after", error.retry_after_ms, "ms")
except TyxterConnectionError as error:
    print("request did not receive an API response", error)

TyxterAPIError.body preserves the original response. Internal errors may also include error.feedback, which points to the public feedback endpoint. A route_not_found response may instead include error.discovery, whose openapi and well_known relative paths describe the API host; read it from the raw body rather than expecting a separate exception attribute.

Webhook verification

Tyxter signs the exact raw request body with HMAC-SHA256(secret, "{timestamp}.{raw_body}"). Never parse and re-serialize the body before verification.

from tyxter import WebhookSignatureVerifier

verifier = WebhookSignatureVerifier(signing_secret)
if not verifier.verify(raw_body=raw_body_bytes, headers=request_headers):
    raise PermissionError("invalid Tyxter webhook signature")

FastAPI:

from fastapi import HTTPException, Request

@app.post("/webhooks/tyxter")
async def tyxter_webhook(request: Request) -> dict[str, bool]:
    raw_body = await request.body()
    if not verifier.verify(raw_body=raw_body, headers=request.headers):
        raise HTTPException(status_code=400, detail="invalid signature")
    return {"received": True}

Django:

from django.http import HttpRequest, JsonResponse

def tyxter_webhook(request: HttpRequest) -> JsonResponse:
    headers = {key: value for key, value in request.headers.items()}
    if not verifier.verify(raw_body=request.body, headers=headers):
        return JsonResponse({"error": "invalid signature"}, status=400)
    return JsonResponse({"received": True})

Header names are case-insensitive. The default replay tolerance is 300 seconds.

Media capability URLs

client.media.upload(...) performs create → capability upload → complete. The SDK deliberately does not attach the Tyxter bearer token to the returned upload URL. Capability failures raise TyxterMediaUploadError.

Client ownership and cleanup

Use the context manager when the SDK creates its own httpx.Client. If you pass http_client=, the caller retains ownership and the SDK will not close it.

import httpx

http_client = httpx.Client()
client = Tyxter(api_key="tx_sandbox_...", http_client=http_client)
client.close()       # does not close http_client
http_client.close()  # caller-owned cleanup

TyxterBootstrap is a separate unauthenticated client for agent API-key device authorization. It never sends a bearer token.

Broadcast example

examples/broadcast_customer_list.py validates a CSV of E.164 phone numbers and sends an approved template through client.batches.create.

python examples/broadcast_customer_list.py \
  --customers examples/customers.csv \
  --from pn_123 \
  --template-name promo_april \
  --template-language en_US \
  --name "April promo" \
  --idempotency-key idem_april_promo_001

Route conformance

conformance/ holds a vendored copy of the canonical public-API manifest:

  • public-api-launch-endpoints.json — every launch route, its scope, query schema, and whether it honors Idempotency-Key / Trace-Id.
  • public-api-query-params.json — the Zod-derived query-parameter names.
  • SOURCE_COMMIT — the Tyxter Messaging commit these copies were taken from.

tests/test_route_conformance.py pins this package's resource surface to that manifest: a route in the manifest with no typed SDK method fails, an SDK method that hits a route the manifest does not define fails, and a query parameter or Idempotency-Key mode (unsupported, supported, or required) that drifts from the contract fails.

The current manifest at ed49c514b74a59de3b238d4ebc157f20482b6171 classifies all 181 rows: 179 have typed SDK routes, while PUT and GET /v1/media/blobs/:token are the two reviewed capability-token exemptions. Those operations use the signed capability token as their sole authority; they are not bearer-authenticated SDK routes. This current-manifest count includes the two post-tag phone-renewal reads, which remain Unreleased; the published sdk-js-v0.8.0 tag scope is separately pinned at 58524926a1fa9498bcfe3abb9d7aa8fd39be1e85. The count is source-conformance evidence, not a publication claim.

Do not hand-edit these files to make a test pass. They are generated in the Tyxter Messaging repo, where they are verified against the mounted v1 controllers, and are updated here only by the automated sync PR. Editing them locally silences the gate instead of fixing the drift — if a sync PR turns the suite red, the correct fix is to add or correct the SDK method.

Metadata

Release files for tyxter 0.8.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for tyxter 0.8.0
File Size Uploaded Provenance
tyxter-0.8.0.tar.gz 156.4 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for tyxter 0.8.0
File Interpreter ABI Platform
tyxter-0.8.0-py3-none-any.whl Python 3 none any Details

Total release size: 229.8 kB

Release files / tyxter-0.8.0.tar.gz

Download URL tyxter-0.8.0.tar.gz
Size 156.4 kB
Tags Source
SHA-256 checksum
How to use checksums
0fa0a6895418ceb35f64f39400a54ffffca4eb1d7105901082bb31ed17a51b9d
BLAKE2b-256 checksum
How to use checksums
9f0ae1e308a48ad26e467ac0824b15836c9eec2817ea0c7793019dea16825cb9
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 3, 2026.

Transparency log

Release files / tyxter-0.8.0-py3-none-any.whl

Download URL tyxter-0.8.0-py3-none-any.whl
Size 73.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
7a11b806e89f62279d10ccd85753e8619536523b4befa0939bee7852821a6b88
BLAKE2b-256 checksum
How to use checksums
d31b25fb40b5a3c91d95785bc5ee2fc0ada7ff9f89612d066c28ba7b0663e898
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 3, 2026.

Transparency log

Security for tyxter 0.8.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for tyxter-0.8.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 3, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Release history Release notifications | RSS feed

This release

0.8.0 This release

2 release files Full provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page