Metadata-Version: 2.4
Name: omnifox
Version: 0.3.0
Summary: Official Python SDK for the Omnifox.io REST API v1 — inbox, CRM, boards, calendar, catalog, quotes and reports. Sync + async, verified end-to-end against a live workspace.
License: MIT
Keywords: omnifox,omnichannel,messaging,whatsapp,sdk,api
Author: Omnifox
Author-email: support@omnifox.io
Requires-Python: >=3.9,<4.0
Classifier: Development Status :: 4 - Beta
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.9
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: Programming Language :: Python :: 3.14
Classifier: Topic :: Communications :: Chat
Classifier: Topic :: Software Development :: Libraries :: Python Modules
Requires-Dist: httpx (>=0.27)
Requires-Dist: pydantic (>=2.0)
Project-URL: Documentation, https://omnifox.io/docs/api
Project-URL: Homepage, https://omnifox.io
Project-URL: Repository, https://github.com/omnifox/sdk-python
Description-Content-Type: text/markdown

# Omnifox — Official Python SDK

The official Python SDK for the [Omnifox.io](https://omnifox.io) REST API v1 — an omnichannel customer messaging platform (WhatsApp, Webchat, Email, SMS, Telegram, and more).

Sync and async clients, pydantic models, retries with backoff, and cursor + page pagination. Every method in this release has been executed against a live Omnifox workspace — **154 calls covering every public method of every resource** — so the shapes below are the ones the server actually accepts.

Since 0.3.0 the SDK covers the whole platform, not just the inbox: CRM (companies, deals, pipelines, activities), Boards, the Calendar, the product catalog and quotes, and the analytics reports.

[![PyPI](https://img.shields.io/pypi/v/omnifox.svg)](https://pypi.org/project/omnifox/)
[![Python](https://img.shields.io/pypi/pyversions/omnifox.svg)](https://pypi.org/project/omnifox/)
[![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](LICENSE)

> **Why this exists**: at the time of writing, [respond.io ships only a TypeScript SDK](https://developers.respond.io/). Omnifox ships **both Python and Node** so your data team can build pipelines, your AI engineers can wire up agents, and your back-office automations don't have to reinvent the wheel.

---

## Install

```bash
pip install omnifox
```

Requirements: Python 3.9+, `httpx>=0.27`, `pydantic>=2.0`.

---

## Quickstart — sync

```python
from omnifox import Omnifox

client = Omnifox("omf_live_...", workspace_id=1)

# Get a contact by id, email, phone, or external id
contact = client.contacts.get("email:ada@example.com")

# Upsert (create-or-update by email/phone/external_id)
contact = client.contacts.upsert(
    email="ada@example.com",
    first_name="Ada",
    last_name="Lovelace",
)

# Send a message
client.messages.send(
    contact="id:42",
    channel_id="whatsapp_main",
    text="Hello from the Omnifox SDK!",
    idempotency_key="welcome-ada-2026-05-07",
)

# Iterate every contact (cursor pagination — server-friendly)
for c in client.contacts.list(pagination="cursor"):
    print(c.id, c.display_name)

client.close()  # or use `with Omnifox(...) as client:`
```



## Beyond the inbox

```python
# CRM
pipeline = client.pipelines.create(name="Ventas 2026")
stage = client.pipelines.create_stage(pipeline.id, name="Ganado", is_won=True)

deal = client.deals.create(title="Implementación ACME", pipeline_id=pipeline.id, amount=12_000)
client.deals.move_to_stage(deal.id, stage_id=stage.id, position=0)
client.deals.close(deal.id, result="won", close_reason_note="Firmado")
client.crm_activities.create("deal", deal.id, title="Kickoff", type="meeting")

# Boards
board = client.boards.create(name="Onboarding", template="blank")
columns = client.boards.columns(board.id)
item = client.board_items.create(board.id, title="Firmar contrato")
client.board_items.set_cell_value(item.id, columns[0]["id"], "Working on it")

# Calendar — a taken slot answers 409
appt = client.appointments.book(calendar_id=1, starts_at="2026-08-01T15:00:00Z", contact_id=42)
client.appointments.cancel(appt.id, reason="El cliente reagendó")

# Catalog + quotes
product = client.products.create(name="Plan Crece", unit_price=49)
quote = client.quotes.create(contact_id=42, items=[{"product_id": product.id, "quantity": 12}])
client.quotes.send(quote.id, email=True, whatsapp=True)
pdf_bytes = client.quotes.pdf(quote.id)          # raw bytes, not JSON

# Reports
client.reports.overview(preset="30d")
client.reports.crm_funnel(preset="this_month")
```

Closing a deal needs the pipeline to own a stage flagged `is_won` / `is_lost`; without one the server refuses with 422. Board items are addressed flat at `/boards/items/{id}` — only creation goes through the board.

## Workspaces

Tokens issued from the **API keys** screen are pinned to one workspace and the server resolves it from the token, so `workspace_id` is optional for them.

Pass it anyway if you can: four endpoints validate `workspace_id` as a **required body field** whatever the token is scoped to — `users.update_status`, `teams.create`, `channels.connect` and `broadcasts.create`. With `workspace_id=` set on the client the SDK fills it in; without it those four raise a clear `ValueError` before any HTTP happens, instead of returning an opaque 422.

## Quickstart — async

```python
import asyncio
from omnifox import AsyncOmnifox

async def main() -> None:
    async with AsyncOmnifox("omf_live_...", workspace_id=1) as client:
        async for contact in client.contacts.list(pagination="cursor"):
            print(contact.id, contact.email)

        await client.messages.send(
            contact="phone:+593987654321",
            whatsapp_template={"name": "welcome_v2", "language": "es"},
        )

asyncio.run(main())
```

---

## Flexible identifiers

Anywhere a resource takes an id, you may pass any of these forms — the SDK URL-encodes the value portion automatically:

| Form                             | Means                                          |
| -------------------------------- | ---------------------------------------------- |
| `42`                             | numeric id                                     |
| `"id:42"`                        | numeric id (explicit prefix, equivalent to `42`) |
| `"email:foo@bar.com"`            | look up by email                               |
| `"phone:+593987654321"`          | look up by phone (E.164)                       |
| `"external:crm-12345"`           | look up by your external CRM id                |

```python
client.contacts.get(42)
client.contacts.get("email:ada@example.com")
client.contacts.get("phone:+593987654321")
client.contacts.get("external:crm-12345")
```

---

## Resources at a glance

Each line is one example call against a resource. All write methods accept an optional `idempotency_key=` keyword argument.

```python
# Contacts ---------------------------------------------------------------
client.contacts.list(pagination="cursor")
client.contacts.get("email:ada@example.com")
client.contacts.create(first_name="Ada", email="ada@example.com")
client.contacts.update("id:42", first_name="Augusta")
client.contacts.upsert(email="ada@example.com", first_name="Ada")
client.contacts.merge(primary_id=42, duplicate_ids=[43, 44])
client.contacts.search("Ada")
client.contacts.attach_tag("id:42", tag_id=7)
client.contacts.attach_tags_by_name("id:42", names=["VIP", "newsletter"])
client.contacts.open_conversation("id:42")
client.contacts.close_conversation("id:42")
client.contacts.assign_conversation("id:42", user_id=99)
client.contacts.set_conversation_status("id:42", status="resolved")
client.contacts.comment("id:42", text="internal note")

# Conversations ----------------------------------------------------------
client.conversations.list(status="open")
client.conversations.get(123)
client.conversations.create(contact_id=42, channel_id=7)
client.conversations.assign(123, user_id=99)
client.conversations.close(123)
client.conversations.reopen(123)
client.conversations.add_note(123, text="follow up tomorrow")

# Messages ---------------------------------------------------------------
client.messages.send(contact="id:42", channel_id=7, text="Hi!")
client.messages.send(conversation_id=123, text="Reply in thread")
client.messages.send(contact="phone:+593...", whatsapp_template={"name": "welcome", "language": "es"})
client.messages.list(123, pagination="cursor")
client.messages.get(123, message_id=999)

# Channels ---------------------------------------------------------------
client.channels.list()
client.channels.get(7)
client.channels.connect(type="whatsapp", name="Sales WA", config={...})
client.channels.disconnect(7)
client.channels.health(7)
client.channels.templates(7)

# Broadcasts -------------------------------------------------------------
client.broadcasts.list()
client.broadcasts.create(name="Spring sale", channel_id=7, segment_filters={...})
client.broadcasts.cancel(99)

# Custom fields ----------------------------------------------------------
client.custom_fields.list()
client.custom_fields.create(name="LTV", type="number")
client.custom_fields.update(5, description="Lifetime value in USD")
client.custom_fields.delete(5)

# Tags -------------------------------------------------------------------
client.tags.list()
client.tags.create(name="VIP", color="#fbbf24")
client.tags.update(7, name="VIP+")
client.tags.delete(7)

# Snippets (canned responses) -------------------------------------------
client.snippets.list()
client.snippets.create(name="Greeting", shortcut="/hi", content_text="Hello {{first_name}}!")
client.snippets.update(3, content_text="Hi {{first_name}}!")
client.snippets.delete(3)

# Webhooks ---------------------------------------------------------------
client.webhooks.list()
client.webhooks.create(name="To Zapier", url="https://hooks.zapier.com/...", events=["message.created"])
client.webhooks.rotate_secret(5)
client.webhooks.delete(5)

# Workflows --------------------------------------------------------------
client.workflows.list()
client.workflows.activate(11)
client.workflows.deactivate(11)
client.workflows.trigger(11, contact_id=42, payload={"reason": "manual"})

# Users / Agents ---------------------------------------------------------
client.users.list()           # alias: client.agents.list()
client.users.get(99)
client.users.update_status(99, status="online")  # online | away | busy | offline

# Workspaces -------------------------------------------------------------
client.workspaces.list()
client.workspaces.create(name="Acme")
client.workspaces.update(1, timezone="America/Guayaquil")

# Teams ------------------------------------------------------------------
client.teams.list()
client.teams.create(name="Tier 1 Support", assignment_strategy="round_robin")
client.teams.add_member(2, user_id=99, role="lead")
client.teams.remove_member(2, user_id=99)
```

Every resource above is also available on `AsyncOmnifox` with the exact same API — write `await` in front of the call (and `async for` for `list()`).

---

## Error handling

All non-2xx responses raise a subclass of `OmnifoxApiError`:

```python
from omnifox import (
    OmnifoxApiError,
    OmnifoxRateLimitError,
    OmnifoxValidationError,
)

try:
    client.contacts.create(first_name="x")
except OmnifoxValidationError as e:
    # 422 — request payload was rejected
    print(e.field_errors)            # {"email": ["required"], ...}
except OmnifoxRateLimitError as e:
    # 429 — SDK already retried `max_retries` times before giving up
    print("retry after", e.retry_after_sec, "seconds")
except OmnifoxApiError as e:
    # any other 4xx/5xx
    print(e.http_status, e.code, e.message, e.request_id)
```

Every exception carries:

- `http_status` — the integer HTTP status code.
- `code` — the API error code (e.g. `"NOT_FOUND"`, `"VALIDATION"`).
- `message` — human-readable message.
- `details` — free-form details dict from the API.
- `request_id` — the `X-Request-Id` header, if present. **Include this when you contact support@omnifox.io.**

---

## Idempotency

Every write method accepts `idempotency_key=`. If the network drops mid-request and you retry with the same key within 24 hours, the API returns the original result without duplicating side effects.

```python
client.messages.send(
    contact="id:42",
    text="One-time discount code: ABC123",
    idempotency_key=f"discount-{order_id}",
)
```

## Retries

The SDK retries on `429` (honouring `Retry-After`), `5xx`, and connection errors using exponential backoff with full jitter. Tune via:

```python
client = Omnifox(
    api_key="...",
    max_retries=5,        # default 3
    backoff_base=0.5,     # initial backoff in seconds
    backoff_max=30.0,     # cap
    timeout=60.0,         # per-request timeout
)
```

## Pagination

Two modes, controlled by the `pagination=` argument:

- `pagination="page"` (default) — Laravel-style page numbers. Best for small/medium result sets.
- `pagination="cursor"` — server-sent opaque cursors. **Use this for large exports** — it's faster and more stable under concurrent writes.

Both modes return an iterator; the SDK fetches subsequent pages lazily.

```python
# Stop after 1000 contacts:
for c in client.contacts.list(pagination="cursor", max_items=1000):
    ...
```

---

## Comparison vs respond.io

| Feature                   | Omnifox Python SDK            | respond.io                     |
| ------------------------- | ----------------------------- | ------------------------------ |
| Python SDK                | **Yes — this package**        | No (only TypeScript)           |
| Node/TypeScript SDK       | Yes (`@omnifox/sdk`)          | Yes                            |
| Sync **and** async client | Yes                           | n/a                            |
| Pydantic models           | v2, full coverage             | n/a                            |
| Cursor pagination         | First-class iterator          | Manual                         |
| Idempotency keys          | Per-method kwarg              | Manual header                  |
| Open source               | MIT                           | n/a                            |

If you're migrating from respond.io: most concepts map 1:1 (Contact, Conversation, Channel, Workflow, Broadcast). The main wins are typed responses and the sync/async parity.

---

## Development

```bash
git clone https://github.com/omnifox/sdk-python
cd sdk-python
poetry install
poetry run pytest
poetry run mypy --strict omnifox
```

---

## License

MIT — see [LICENSE](LICENSE).

