Metadata-Version: 2.4
Name: tabi-sdk
Version: 0.3.1
Summary: Official Python SDK for the Tabi WhatsApp business messaging API
Author: Tabi
License-Expression: MIT
License-File: LICENSE
Keywords: api,messaging,sdk,tabi,whatsapp
Requires-Python: >=3.10
Requires-Dist: requests<3,>=2.28.0
Provides-Extra: dev
Requires-Dist: pytest>=7.0; extra == 'dev'
Description-Content-Type: text/markdown

# tabi-sdk (Python)

Official Python client for the [Tabi](https://tabi.africa) WhatsApp Business API. Matches the JavaScript and PHP SDKs where the REST surface is the same.

[PyPI](https://pypi.org/project/tabi-sdk/)
[License: MIT](LICENSE)

## Install

```bash
pip install tabi-sdk
```

From this repo (editable):

```bash
cd packages/tabi-sdk-python
pip install -e .
```

## Quick start

```python
import os

from tabi_sdk import TabiClient

client = TabiClient(
    api_key=os.environ["TABI_API_KEY"],
    base_url="https://api.tabi.africa/api/v1",
)

client.messages.send(
    "your-channel-id",
    {
        "to": "2347000000000",
        "content": "Hello",
    },
)
```

Create an API key under **Developer → API keys**. Use the channel ID from **Channels** (URL or detail view). `to` is international digits only, no `+`.

Prefer loading the key from the environment (see below); do not commit keys or paste them into shared chat logs.

## Configuration

```python
import os
from tabi_sdk import TabiClient

client = TabiClient(
    api_key=os.environ["TABI_API_KEY"],
    base_url=os.environ.get("TABI_BASE_URL", "https://api.tabi.africa/api/v1"),
    timeout=30.0,
)
```

## Client layout


| Attribute                                                                             | REST area                    |
| ------------------------------------------------------------------------------------- | ---------------------------- |
| `auth`, `workspaces`                                                                  | Auth, workspaces, members    |
| `channels`, `messages`, `conversations`, `contacts`, `quick_replies`, `notifications` | Lines, sends, inbox          |
| `automation_templates`, `automation_installs`, `campaigns`                            | Automations, broadcasts      |
| `api_keys`, `webhooks`, `integrations`                                                | Keys, webhooks, integrations |
| `files`, `analytics`                                                                  | Media, metrics               |

## Parameters, events, and “all methods”

**Before:** the README showed **examples** and **method names**, but not every field in one place.

**Use this split:**

1. **This README** — quick **send** body for direct text/media, **webhook event names**, and the **Resources** section (all `client.*` methods we ship).
2. **Tabi Dashboard → Developer → API reference** (OpenAPI) — **authoritative** list of every property, enum, query string, and response shape for **every** route. Use it when a developer asks “what parameters can I set?”

### `messages.send` — JSON body for `POST /channels/{channelId}/send`

| Field | Required | Description |
|-------|----------|-------------|
| `to` | yes | Recipient phone (digits; international, no leading `+` in JSON). |
| `content` | yes | Text or caption, max 4096 chars. |
| `messageType` | no | `text` (default), `image`, `video`, `audio`, `document`. |
| `mediaUrl` | for media | Required when `messageType` is not `text` (public URL or base64 data URI). |
| `messageClass` | no | `transactional` (default for API), `conversational_reply`, `triggered_followup`, `broadcast`. |
| `contactName` | no | Display name when creating a new contact. |
| `channelId` | no | Optional; if set, must match the `channelId` in the URL path. |

Stickers, polls, location, contacts, reactions, etc. use **other** endpoints (see **Messages** under [Resources](#resources)); those have their own bodies in OpenAPI.

### Webhook `events` (for `client.webhooks.create`)

Subscribe with a list of event names. Use `*` to receive all. Common values:

| Event | Meaning |
|-------|---------|
| `message.inbound` | New message received on a channel. |
| `message.status` | Outbound message status changed (e.g. delivered, read, failed). |
| `conversation.created` | New conversation started. |

Delivery body shape: `{ "event": "<name>", "data": { ... }, "timestamp": "<ISO8601>" }`. Verify signatures using your subscription secret (see API reference).

## OTP over WhatsApp

### Hosted OTP on the Tabi API (recommended)

The API can generate the code, store a hash in Redis, send WhatsApp, and verify submissions—same auth as other channel routes (`messages:send` scope, etc.):

```python
client.channels.send_otp("channel-uuid", {"phone": "+2347000000000"})
client.channels.verify_otp("channel-uuid", {"phone": "+2347000000000", "code": "123456"})
```

REST: `POST /channels/{channelId}/otp/send`, `POST /channels/{channelId}/otp/verify`. Your customer’s site calls **your** backend; your backend calls Tabi with the workspace API key.

### WhatsApp / Meta policy (what Tabi covers vs. what you control)

**On the Tabi side**, OTP traffic goes through the same WhatsApp Business connection as your other sends: we queue **transactional** delivery, enforce **API rate limits** on send/verify, and keep codes off your logs—so typical integration mistakes (blasting numbers, hammering verify, treating OTP like a broadcast channel) are blocked **before** they become a Meta policy incident.

**Templates and WABA rules still live in Meta.** You manage your **WhatsApp Business Account** and any **message templates** Meta requires for your region or use case inside **Meta Business Manager**. Tabi does not replace that—but you are not expected to hand-craft low-level WhatsApp protocol details in your app; you call the API, and we route delivery appropriately.

**What we ask of you and your customers:** use OTP only for **real sign-in / verification** flows, only for people who **should** get that message (e.g. they just requested a code on your site), and **do not abuse the API** (no cold outreach, no marketing disguised as OTP, no trying to bypass limits). Follow [Meta’s WhatsApp documentation](https://developers.facebook.com/docs/whatsapp) for opt-in and template rules that apply to your WABA.

No platform can promise Meta will never flag an account; if something is unclear, check Meta’s current policies and your BSP’s guidance.

### DIY flow (optional helpers)

If you do not use the hosted OTP routes, you can still send a custom body with `POST /channels/{channelId}/send` and manage storage yourself. Helpers: `generate_numeric_otp`, `normalize_phone_digits`, `build_otp_message`.

```python
import hashlib
import hmac
import os

import redis  # or your own store

from tabi_sdk import TabiClient, TabiError, build_otp_message, generate_numeric_otp, normalize_phone_digits

r = redis.Redis.from_url(os.environ["REDIS_URL"])
client = TabiClient(api_key=os.environ["TABI_API_KEY"])
channel_id = os.environ["TABI_WHATSAPP_CHANNEL_ID"]


def send_login_otp(raw_phone: str) -> None:
    phone = normalize_phone_digits(raw_phone)
    code = generate_numeric_otp(6, secure=True)
    key = f"otp:login:{phone}"
    payload = hashlib.sha256(f"{code}:{phone}".encode()).hexdigest()
    r.setex(key, 600, payload)

    body = build_otp_message(code=code, brand_name="MyApp", expiry_minutes=10)
    try:
        client.messages.send(
            channel_id,
            {"to": phone, "content": body, "messageClass": "transactional"},
        )
    except TabiError as e:
        raise RuntimeError(f"send failed: {e.status}") from e


def verify_login_otp(raw_phone: str, user_entered: str) -> bool:
    phone = normalize_phone_digits(raw_phone)
    key = f"otp:login:{phone}"
    stored = r.get(key)
    if not stored:
        return False
    expected = stored.decode()
    candidate = hashlib.sha256(f"{user_entered.strip()}:{phone}".encode()).hexdigest()
    if not hmac.compare_digest(candidate, expected):
        return False
    r.delete(key)
    return True
```

Operational notes:

- Do not log OTPs or bodies that contain them. Use generic errors to users (“Invalid or expired code”).
- Cap send and verify attempts per phone (and per IP on web). On HTTP 429 from the API, back off; do not retry in a tight loop.
- Use `messageClass: "transactional"` for OTP-style traffic when using `messages.send`.

## Resources

Method names are `snake_case`. JSON bodies use the same keys as the HTTP API (e.g. `refreshToken`).

### Auth

```python
client.auth.login(os.environ["TABI_USER_EMAIL"], os.environ["TABI_USER_PASSWORD"])
client.auth.register({...})
client.auth.refresh("refresh_token_from_api")
client.auth.me()
client.auth.logout()
client.auth.invite_preview("invite_token")
```

### Channels

```python
client.channels.list()
client.channels.get("channel-id")
client.channels.create({"name": "Support", "provider": "go-whatsapp"})
client.channels.connect("channel-id")
client.channels.disconnect("channel-id")
client.channels.status("channel-id")
client.channels.update("channel-id", {"name": "Renamed"})
client.channels.reconnect("channel-id")
client.channels.delete("channel-id")
client.channels.send_otp("channel-id", {"phone": "+2347000000000"})
client.channels.verify_otp("channel-id", {"phone": "+2347000000000", "code": "123456"})
```

### Messages

```python
client.messages.send(
    "channel-id",
    {"to": "2347000000000", "content": "Hello", "messageClass": "transactional"},
)
client.messages.send(
    "channel-id",
    {
        "to": "2347000000000",
        "content": "See image",
        "messageType": "image",
        "mediaUrl": "https://cdn.example.com/a.png",
    },
)
client.messages.get("message-id")
client.messages.list_by_conversation("conversation-id", {"page": 1, "limit": 50})
client.messages.reply("conversation-id", {"content": "Reply text"})
client.messages.send_sticker("channel-id", {...})
client.messages.send_contact("channel-id", {...})
client.messages.send_location("channel-id", {"to": "234...", "latitude": 6.5, "longitude": 3.3})
client.messages.send_poll("channel-id", {...})
client.messages.react("channel-id", "message-id", {"emoji": "👍"})
client.messages.mark_read("channel-id", "message-id")
client.messages.revoke("channel-id", "message-id")
client.messages.edit("channel-id", "message-id", {"content": "Edited"})
client.messages.download_media("channel-id", "message-id")
```

### Contacts

```python
client.contacts.list({"page": 1, "search": "John"})
client.contacts.get("contact-id")
client.contacts.create({"phone": "2347000000000", "firstName": "Jane"})
client.contacts.update("contact-id", {"firstName": "Janet"})
client.contacts.delete("contact-id")
client.contacts.import_contacts({"contacts": [...]})
client.contacts.get_tags("contact-id")
client.contacts.add_tag("contact-id", "vip")
client.contacts.remove_tag("contact-id", "vip")
client.contacts.opt_in("contact-id")
client.contacts.opt_out("contact-id")
```

### Conversations

```python
client.conversations.list({"status": "open", "page": 1})
client.conversations.get("conversation-id")
client.conversations.update("conversation-id", {"assignedTo": "member-uuid"})
client.conversations.resolve("conversation-id")
client.conversations.reopen("conversation-id")
client.conversations.mark_read("conversation-id")
```

### Webhooks

```python
client.webhooks.create({...})
client.webhooks.list()
client.webhooks.get("id")
client.webhooks.update("id", {...})
client.webhooks.delete("id")
client.webhooks.ping("id")
client.webhooks.rotate_secret("id")
client.webhooks.delivery_logs({"page": 1})
client.webhooks.start_test_capture()
client.webhooks.stop_test_capture()
client.webhooks.test_capture_status()
```

### API keys

Creating keys needs a user JWT, not a workspace API key.

```python
client.api_keys.create(
    {"name": "Production", "scopes": ["messages:send", "channels:read"]}
)
client.api_keys.list()
client.api_keys.revoke("key-id")
client.api_keys.delete("key-id")
```

### Files

```python
client.files.list()
client.files.get("file-id")
client.files.get_url("file-id")
client.files.delete("file-id")
```

### Campaigns

```python
client.campaigns.create({...})
client.campaigns.list({"page": 1})
client.campaigns.get("id")
client.campaigns.update("id", {...})
client.campaigns.delete("id")
client.campaigns.schedule("id")
client.campaigns.pause("id")
client.campaigns.resume("id")
client.campaigns.cancel("id")
```

### Automation templates

```python
client.automation_templates.list()
client.automation_templates.get("template-id")
```

### Automation installs

```python
client.automation_installs.install({...})
client.automation_installs.list()
client.automation_installs.get("id")
client.automation_installs.update("id", {...})
client.automation_installs.enable("id")
client.automation_installs.disable("id")
client.automation_installs.uninstall("id")
```

### Quick replies

```python
client.quick_replies.list()
client.quick_replies.create({"shortcut": "/hi", "body": "Hello!"})
client.quick_replies.update("id", {"body": "..."})
client.quick_replies.delete("id")
```

### Analytics

```python
client.analytics.dashboard({"from": "2026-01-01", "to": "2026-01-31"})
client.analytics.channels({...})
client.analytics.conversations({...})
```

### Notifications

```python
client.notifications.list({"page": 1})
client.notifications.mark_read("id")
client.notifications.mark_all_read()
client.notifications.unread_count()
```

### Integrations

```python
client.integrations.list_providers()
client.integrations.create({...})
client.integrations.list()
client.integrations.get("id")
client.integrations.update("id", {...})
client.integrations.delete("id")
client.integrations.test("id")
```

### Workspaces

```python
client.workspaces.list()
client.workspaces.get("workspace-id")
client.workspaces.create({"name": "Team"})
client.workspaces.update("workspace-id", {"name": "Renamed"})
client.workspaces.list_members("workspace-id")
client.workspaces.invite_member("workspace-id", {"email": "member@example.com", "roleSlug": "admin"})
```

## Error handling

Failures raise `TabiError` with `status`, `body`, and a message.

```python
import os

from tabi_sdk import TabiClient, TabiError

client = TabiClient(api_key=os.environ["TABI_API_KEY"], base_url="https://api.tabi.africa/api/v1")

try:
    client.messages.send("channel-id", {"to": "234...", "content": "Hi"})
except TabiError as e:
    print(e.status, e.body)
```


| Status | Typical cause             |
| ------ | ------------------------- |
| `400`  | Invalid payload           |
| `401`  | Bad or expired credential |
| `403`  | Missing scope             |
| `404`  | Not found                 |
| `429`  | Rate limited              |
| `500`  | Server error              |


## Requirements

- Python >= 3.10
- `requests` >= 2.28

## License

MIT — see [LICENSE](LICENSE).