# Python SDK

> Use the ody Python package: setup, pagination, errors, idempotency and webhook verification. Standard library only.

Source: https://dev.ody.co/sdks/python

> **Preview:**
`ody-sdk` isn't on PyPI yet; see [availability](https://dev.ody.co/sdks/overview). The API it wraps is stable and live.

## Setup

```bash
pip install ody-sdk   # once published
```

```python
import os
from ody import Ody

client = Ody(os.environ["ODY_API_KEY"])
# Options: Ody(api_key, base_url="https://api.ody.co", timeout=30.0)
```

Python 3.9+, no third-party dependencies (it uses `urllib`). The client is synchronous. In async code, run calls in a thread (`await asyncio.to_thread(client.messages.send, ...)`).

## Usage

```python
# Contacts
contact = client.contacts.create(first_name="Ada", phone="+15125550123", tags=["vip"])
for c in client.contacts.list_all(q="ada"):
    print(c["id"])

# Conversations
page = client.conversations.list(status="open", limit=25)
thread = client.conversations.get(page["conversations"][0]["id"])
client.conversations.set_status(thread["conversation"]["id"], "done")

# Messages
sent = client.messages.send(to="+15125550123", body="Hi!", idempotency_key="welcome-ada")
hits = client.messages.search("refund", limit=10)

# Calls
calls = client.calls.list(limit=20)
client.calls.place(to="+15125550123")

# Numbers, 10DLC, agent
available = client.numbers.search_available(starts_with="512")
client.numbers.buy(phone_number=available[0]["phone_number"], idempotency_key="main-line")
status = client.messaging.status()
client.agent.update(greeting="Thanks for calling!")
client.agent.publish()
```

Responses are plain `dict`s with the API's camelCase keys (`conversationId`, `nextCursor`). Python argument names are snake_case (`first_name`, `idempotency_key`, `from_` for the reserved word `from`).

## Errors

```python
import time
from ody import OdyError

try:
    client.messages.send(to="+15125550123", body="Hi")
except OdyError as err:
    if err.code == "resource_exhausted":
        time.sleep(err.retry_after or 1)
    elif err.code == "failed_precondition":
        print("Not allowed yet:", err.message)  # e.g. 10DLC pending
    print(err.status, err.code, err.request_id)
```

The codes are listed in [Errors](https://dev.ody.co/api/errors).

## Webhook verification

```python
from ody import verify_webhook_signature

ok = verify_webhook_signature(secret, request.headers, request.get_data())  # raw bytes
```

Header lookup is case-insensitive, the comparison is constant-time, and timestamps more than 5 minutes off are rejected. Flask and `standardwebhooks` examples are in [Verifying signatures](https://dev.ody.co/webhooks/signatures).

## Webhooks

`parse_webhook_event` verifies and parses in one step and returns the envelope dict (`id`, `apiVersion`, `createdAt`, `type`, `data`). It raises `WebhookVerificationError` on a bad or stale signature:

```python
from flask import Flask, request
from ody import parse_webhook_event, WebhookVerificationError, is_known_webhook_event_type

@app.post("/ody-webhook")
def ody_webhook():
    try:
        event = parse_webhook_event(os.environ["ODY_WEBHOOK_SECRET"], request.headers, request.get_data())
    except WebhookVerificationError:
        return "", 401

    if event["type"] == "call.ringing":
        screen_pop(event["data"]["from"], event["data"]["callerName"])
    elif event["type"] == "call.answered" and event["data"]["answeredBy"] == "member":
        assign(event["data"]["memberUid"])
    elif event["type"] in ("contact.updated", "contact.deleted"):
        sync_contact(event["data"]["contactId"])
    elif not is_known_webhook_event_type(event["type"]):
        log.info("new Ody event type %s", event["type"])   # a "*" subscription may see types newer than this SDK
    return "", 200
```

`WEBHOOK_EVENT_TYPES` is the catalog as a tuple. Each event's `data` fields are described in [Event types](https://dev.ody.co/webhooks/events).
