Skip to main content

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.

Metadata

Release files for omnifox 0.4.1

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

Source distribution (sdist)

Source distribution for omnifox 0.4.1
File Size Uploaded Provenance
omnifox-0.4.1.tar.gz 40.2 kB Without provenance Details

Built distribution (wheel)

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

Total release size: 93.3 kB

Release files / omnifox-0.4.1.tar.gz

Download URL omnifox-0.4.1.tar.gz
Size 40.2 kB
Tags Source
SHA-256 checksum
How to use checksums
11dc60526335f3d6fce0ce80570821313e719212a649c8a6ba0e6c0c7eecfa2e
BLAKE2b-256 checksum
How to use checksums
db68aec60536e440b81a036ae79bae2334d52508b7f04b29d9885441df5b677e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.12.3

Release files / omnifox-0.4.1-py3-none-any.whl

Download URL omnifox-0.4.1-py3-none-any.whl
Size 53.1 kB
Tags Python 3
SHA-256 checksum
How to use checksums
84c740a8a7f02edde32e1c73174855edb6bec2ac78c760ac5424989d44800a55
BLAKE2b-256 checksum
How to use checksums
06a024182a62e8132bd67c8903a4095d73e9c98e3b4553bb6f69959a9c9558cb
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/6.2.0 CPython/3.12.3

Security for omnifox 0.4.1

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.

No provenance found

None of the files in this release were uploaded with provenance metadata.

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.

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