Metadata-Version: 2.5
Name: tyxter
Version: 0.8.0
Summary: Python SDK for the Tyxter Messaging API.
Project-URL: Homepage, https://docs.tyxter.com
Project-URL: Repository, https://github.com/tyxter-dev/tyxter-python
Project-URL: Changelog, https://github.com/tyxter-dev/tyxter-python/blob/main/CHANGELOG.md
Author: Tyxter
License-Expression: MIT
License-File: LICENSE
Keywords: messaging,sdk,tyxter,whatsapp
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.10
Requires-Dist: httpx<1,>=0.28
Requires-Dist: typing-extensions<5,>=4.12
Provides-Extra: dev
Requires-Dist: build<2,>=1.2; extra == 'dev'
Requires-Dist: hatchling<2,>=1.26; extra == 'dev'
Requires-Dist: mypy<2,>=1.14; extra == 'dev'
Requires-Dist: pytest<9,>=8; extra == 'dev'
Requires-Dist: ruff<1,>=0.8; extra == 'dev'
Description-Content-Type: text/markdown

# 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:

```bash
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:

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

## First message

```python
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.

```python
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.

```python
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.

```bash
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.

```python
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.

```python
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:

```python
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.

```python
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.

```python
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:

```python
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:

```python
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.

```python
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`.

```bash
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.
