Metadata-Version: 2.4
Name: tabi-sdk
Version: 0.3.0
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               |


## 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 (risk reduction)

Tabi implements delivery and OTP storage; **you and your customers** remain responsible for **Meta’s rules** for the WhatsApp Business Account. In practice:

- **Consent:** Only message people who agreed to WhatsApp contact for that purpose (e.g. checked a box on the site, or an existing transactional relationship—follow Meta’s opt-in guidance for your region).
- **Purpose:** Use OTP / login flows for **authentication or transactional** use cases; do not use them as a cover for cold marketing or spam.
- **Templates:** Where Meta requires **pre-approved message templates** (categories differ by use case and region), register and use them; for many **authentication** flows Meta expects **Authentication**-style templates—confirm in [Meta’s WhatsApp documentation](https://developers.facebook.com/docs/whatsapp) for your WABA.
- **Quality:** High block/report rates hurt your **phone number quality rating**. Rate-limit sends and verification attempts; avoid blasting users.
- **Accuracy:** Message content should match what the user opted into; do not mislead.

Nothing in software alone “guarantees” approval; when in doubt, review Meta’s current **Commerce** and **Business** 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).