Skip to main content

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.

Project description

Omnifox — Official Python SDK

The official Python SDK for the 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 Python License: MIT

Why this exists: at the time of writing, respond.io ships only a TypeScript SDK. 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

pip install omnifox

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


Quickstart — sync

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

# 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

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

# 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)
# Handing it to a team applies THAT TEAM's distribution rule (default "shared"
# = no owner, straight to the team inbox). Override it for this call only:
client.conversations.assign(
    123, team_id=2, assign_logic="round_robin", only_online=True, max_open=5
)
client.conversations.close(123)
client.conversations.reopen(123)
client.conversations.add_note(123, text="follow up tomorrow")

# Before writing to a WhatsApp thread: is the 24h window still open?
# Channels with no window always answer open=True.
w = client.conversations.window_status(123)
if w.open:
    client.messages.send(conversation_id=123, text="On its way!")
else:
    # w.reason explains it; only an approved template gets through now
    client.messages.send(
        conversation_id=123,
        whatsapp_template={"name": "order_update", "language": "es"},
    )

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

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.

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:

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.

# 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

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.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

omnifox-0.3.2.tar.gz (38.6 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

omnifox-0.3.2-py3-none-any.whl (51.7 kB view details)

Uploaded Python 3

File details

Details for the file omnifox-0.3.2.tar.gz.

File metadata

  • Download URL: omnifox-0.3.2.tar.gz
  • Upload date:
  • Size: 38.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.12.3

File hashes

Hashes for omnifox-0.3.2.tar.gz
Algorithm Hash digest
SHA256 9c9fb401f183b3560a1567579201dc01514aaa93bef14451bccb89b0f7715d45
MD5 86b2155ed9c785a60fd1d3ef29b44ecb
BLAKE2b-256 c9c2d961f317eb8d1fd075151d2fa8825017b808121c2cdb7ab7e04e1949b780

See more details on using hashes here.

File details

Details for the file omnifox-0.3.2-py3-none-any.whl.

File metadata

  • Download URL: omnifox-0.3.2-py3-none-any.whl
  • Upload date:
  • Size: 51.7 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.12.3

File hashes

Hashes for omnifox-0.3.2-py3-none-any.whl
Algorithm Hash digest
SHA256 ae36beeb935ed5b26396f8adb9860f9894f4405a88bfe957fca80204902a7339
MD5 2c8c9eb727c0e205ad69de4a90aa5562
BLAKE2b-256 430756b283b6e8a3e35ab7bb30e5b26876c6fe882b989aa49ad157e0d147ff4f

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page